Merge upstream master and move the reveal docs into docs/
Upstream moved the documentation sources from doc/ to docs/ (#7584), so the page this branch adds now belongs under docs/doxygen/pages/user_documentation/ gameplay/. The index.md edit auto-merged onto the moved file, but the new page stayed at the old path, and GitHub's merge engine reported a conflict in index.md because the file it edits was deleted upstream. No source changes: the merge is upstream/master as-is plus the doc relocation.
|
|
@ -1,9 +1,9 @@
|
||||||
from archlinux:latest
|
FROM archlinux:latest
|
||||||
|
|
||||||
RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \
|
RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \
|
||||||
base-devel \
|
|
||||||
ccache \
|
ccache \
|
||||||
cmake \
|
cmake \
|
||||||
|
gcc \
|
||||||
git \
|
git \
|
||||||
gtest \
|
gtest \
|
||||||
mariadb-libs \
|
mariadb-libs \
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
FROM debian:12
|
FROM debian:12-slim
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
ccache \
|
ccache \
|
||||||
cmake \
|
cmake \
|
||||||
|
dpkg-dev \
|
||||||
file \
|
file \
|
||||||
g++ \
|
g++ \
|
||||||
git \
|
git \
|
||||||
libcurl4-openssl-dev \
|
libcurl4-openssl-dev \
|
||||||
libgl-dev \
|
libgl-dev \
|
||||||
|
libgtest-dev \
|
||||||
liblzma-dev \
|
liblzma-dev \
|
||||||
libmariadb-dev-compat \
|
libmariadb-dev-compat \
|
||||||
libprotobuf-dev \
|
libprotobuf-dev \
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
FROM debian:13
|
FROM debian:13-slim
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
ccache \
|
ccache \
|
||||||
cmake \
|
cmake \
|
||||||
|
dpkg-dev \
|
||||||
file \
|
file \
|
||||||
g++ \
|
g++ \
|
||||||
git \
|
git \
|
||||||
libcurl4-openssl-dev \
|
libcurl4-openssl-dev \
|
||||||
libgl-dev \
|
libgl-dev \
|
||||||
|
libgtest-dev \
|
||||||
liblzma-dev \
|
liblzma-dev \
|
||||||
libmariadb-dev-compat \
|
libmariadb-dev-compat \
|
||||||
libprotobuf-dev \
|
libprotobuf-dev \
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
FROM fedora:44
|
FROM fedora:44
|
||||||
|
|
||||||
RUN dnf install -y \
|
RUN dnf install -y --nodocs \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
ccache \
|
ccache \
|
||||||
cmake \
|
cmake \
|
||||||
gcc-c++ \
|
gcc-c++ \
|
||||||
git \
|
git \
|
||||||
|
gtest-devel \
|
||||||
libcurl-devel \
|
libcurl-devel \
|
||||||
mariadb-devel \
|
mariadb-devel \
|
||||||
ninja-build \
|
ninja-build \
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
FROM fedora:43
|
FROM fedora:45
|
||||||
|
|
||||||
RUN dnf install -y \
|
RUN dnf install -y --nodocs \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
ccache \
|
ccache \
|
||||||
cmake \
|
cmake \
|
||||||
gcc-c++ \
|
gcc-c++ \
|
||||||
git \
|
git \
|
||||||
|
gtest-devel \
|
||||||
libcurl-devel \
|
libcurl-devel \
|
||||||
mariadb-devel \
|
mariadb-devel \
|
||||||
ninja-build \
|
ninja-build \
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
FROM debian:12
|
FROM debian:12-slim
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
|
||||||
ccache \
|
ccache \
|
||||||
cmake \
|
cmake \
|
||||||
|
dpkg-dev \
|
||||||
file \
|
file \
|
||||||
g++ \
|
g++ \
|
||||||
git \
|
git \
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,16 @@ FROM ubuntu:24.04
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
ccache \
|
ccache \
|
||||||
cmake \
|
cmake \
|
||||||
|
dpkg-dev \
|
||||||
file \
|
file \
|
||||||
g++ \
|
g++ \
|
||||||
git \
|
git \
|
||||||
libcurl4-openssl-dev \
|
libcurl4-openssl-dev \
|
||||||
libgl-dev \
|
libgl-dev \
|
||||||
|
libgtest-dev \
|
||||||
liblzma-dev \
|
liblzma-dev \
|
||||||
libmariadb-dev-compat \
|
libmariadb-dev-compat \
|
||||||
libprotobuf-dev \
|
libprotobuf-dev \
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,17 @@ FROM ubuntu:26.04
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
ccache \
|
ccache \
|
||||||
cmake \
|
cmake \
|
||||||
|
dpkg-dev \
|
||||||
file \
|
file \
|
||||||
g++ \
|
g++ \
|
||||||
gcovr \
|
gcovr \
|
||||||
git \
|
git \
|
||||||
libcurl4-openssl-dev \
|
libcurl4-openssl-dev \
|
||||||
libgl-dev \
|
libgl-dev \
|
||||||
|
libgtest-dev \
|
||||||
liblzma-dev \
|
liblzma-dev \
|
||||||
libmariadb-dev-compat \
|
libmariadb-dev-compat \
|
||||||
libprotobuf-dev \
|
libprotobuf-dev \
|
||||||
|
|
|
||||||
234
.ci/compile.sh
|
|
@ -20,6 +20,7 @@
|
||||||
# --profile writes an MSBuild performance summary and binary log, Windows only
|
# --profile writes an MSBuild performance summary and binary log, Windows only
|
||||||
# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION BUILD_PARALLEL_LEVEL BUILD_PROFILE
|
# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION BUILD_PARALLEL_LEVEL BUILD_PROFILE
|
||||||
# (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --sanitize --coverage --ccache <ccache_size> --dir <dir> --profile)
|
# (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --sanitize --coverage --ccache <ccache_size> --dir <dir> --profile)
|
||||||
|
# boolean env flags are enabled with the value 1 (e.g. MAKE_SERVER=1)
|
||||||
# exitcode: 1 for failure, 3 for invalid arguments
|
# exitcode: 1 for failure, 3 for invalid arguments
|
||||||
|
|
||||||
# Read arguments
|
# Read arguments
|
||||||
|
|
@ -138,17 +139,21 @@ done
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Setup
|
# Schema version consistency (only relevant to the server)
|
||||||
./servatrice/check_schema_version.sh
|
if [[ ${MAKE_SERVER:-} == "1" ]]; then
|
||||||
if [[ ! $BUILDTYPE ]]; then
|
./servatrice/check_schema_version.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use default values if unset
|
||||||
|
if [[ -z $BUILDTYPE ]]; then
|
||||||
BUILDTYPE=Release
|
BUILDTYPE=Release
|
||||||
fi
|
fi
|
||||||
if [[ ! $BUILD_DIR ]]; then
|
if [[ -z $BUILD_DIR ]]; then
|
||||||
BUILD_DIR="build"
|
BUILD_DIR="build"
|
||||||
fi
|
fi
|
||||||
mkdir -p "$BUILD_DIR"
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
if [[ $MAKE_COVERAGE ]]; then
|
if [[ ${MAKE_COVERAGE:-} == "1" ]]; then
|
||||||
# Resolved while the working directory is still the source root. $0 is relative to the
|
# Resolved while the working directory is still the source root. $0 is relative to the
|
||||||
# directory the script was invoked from, so after the cd below it no longer resolves, and
|
# directory the script was invoked from, so after the cd below it no longer resolves, and
|
||||||
# under `set -e` the failed subshell would abort the build rather than just yielding an empty
|
# under `set -e` the failed subshell would abort the build rather than just yielding an empty
|
||||||
|
|
@ -163,16 +168,16 @@ export CMAKE_POLICY_VERSION_MINIMUM=3.10
|
||||||
|
|
||||||
# Add cmake flags
|
# Add cmake flags
|
||||||
flags=("-DCMAKE_BUILD_TYPE=$BUILDTYPE")
|
flags=("-DCMAKE_BUILD_TYPE=$BUILDTYPE")
|
||||||
if [[ $MAKE_SERVER ]]; then
|
if [[ ${MAKE_SERVER:-} == "1" ]]; then
|
||||||
flags+=("-DWITH_SERVER=1")
|
flags+=("-DWITH_SERVER=1")
|
||||||
fi
|
fi
|
||||||
if [[ $MAKE_NO_CLIENT ]]; then
|
if [[ ${MAKE_NO_CLIENT:-} == "1" ]]; then
|
||||||
flags+=("-DWITH_CLIENT=0" "-DWITH_ORACLE=0")
|
flags+=("-DWITH_CLIENT=0" "-DWITH_ORACLE=0")
|
||||||
fi
|
fi
|
||||||
if [[ $MAKE_TEST ]]; then
|
if [[ ${MAKE_TEST:-} == "1" ]]; then
|
||||||
flags+=("-DTEST=1")
|
flags+=("-DTEST=1")
|
||||||
fi
|
fi
|
||||||
if [[ $MAKE_SANITIZE ]]; then
|
if [[ ${MAKE_SANITIZE:-} == "1" ]]; then
|
||||||
flags+=("-DENABLE_SANITIZERS=ON")
|
flags+=("-DENABLE_SANITIZERS=ON")
|
||||||
else
|
else
|
||||||
# Every leg states the flag explicitly rather than relying on the CMake
|
# Every leg states the flag explicitly rather than relying on the CMake
|
||||||
|
|
@ -180,20 +185,20 @@ else
|
||||||
# never leak libasan/libubsan into a shipped binary.
|
# never leak libasan/libubsan into a shipped binary.
|
||||||
flags+=("-DENABLE_SANITIZERS=OFF")
|
flags+=("-DENABLE_SANITIZERS=OFF")
|
||||||
fi
|
fi
|
||||||
if [[ $MAKE_COVERAGE ]]; then
|
if [[ ${MAKE_COVERAGE:-} == "1" ]]; then
|
||||||
flags+=("-DENABLE_COVERAGE=ON")
|
flags+=("-DENABLE_COVERAGE=ON")
|
||||||
fi
|
fi
|
||||||
if [[ $USE_CCACHE ]]; then
|
if [[ ${USE_CCACHE:-} == "1" ]]; then
|
||||||
flags+=("-DUSE_CCACHE=1")
|
flags+=("-DUSE_CCACHE=1")
|
||||||
if [[ $CCACHE_SIZE ]]; then
|
if [[ -n $CCACHE_SIZE ]]; then
|
||||||
# note, this setting persists after running the script
|
# This setting persists after running the script
|
||||||
ccache --max-size "$CCACHE_SIZE"
|
ccache --max-size "$CCACHE_SIZE"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
if [[ $PACKAGE_TYPE ]]; then
|
if [[ -n $PACKAGE_TYPE ]]; then
|
||||||
flags+=("-DCPACK_GENERATOR=$PACKAGE_TYPE")
|
flags+=("-DCPACK_GENERATOR=$PACKAGE_TYPE")
|
||||||
fi
|
fi
|
||||||
if [[ $USE_VCPKG ]]; then
|
if [[ ${USE_VCPKG:-} == "1" ]]; then
|
||||||
flags+=("-DUSE_VCPKG=1")
|
flags+=("-DUSE_VCPKG=1")
|
||||||
flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions")
|
flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions")
|
||||||
fi
|
fi
|
||||||
|
|
@ -203,6 +208,11 @@ if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then
|
||||||
# developer builds never reach Sentry.
|
# developer builds never reach Sentry.
|
||||||
flags+=("-DWITH_CRASH_REPORTING=ON")
|
flags+=("-DWITH_CRASH_REPORTING=ON")
|
||||||
fi
|
fi
|
||||||
|
if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then
|
||||||
|
# CI lowers the DWARF level so one build configuration fits its compiler-cache bound;
|
||||||
|
# see the option in CMakeLists.txt. Empty keeps the built-in flags.
|
||||||
|
flags+=("-DCOCKATRICE_DEBUG_INFO_LEVEL=$COCKATRICE_DEBUG_INFO_LEVEL")
|
||||||
|
fi
|
||||||
if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then
|
if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then
|
||||||
# Release-only: Debug builds stay DSN-free so crashes from developer builds
|
# Release-only: Debug builds stay DSN-free so crashes from developer builds
|
||||||
# never reach Sentry. Empty (local builds, forks without the secret, and non-tag
|
# never reach Sentry. Empty (local builds, forks without the secret, and non-tag
|
||||||
|
|
@ -216,7 +226,8 @@ fi
|
||||||
buildflags=(--config "$BUILDTYPE")
|
buildflags=(--config "$BUILDTYPE")
|
||||||
|
|
||||||
function ccachestatsverbose() {
|
function ccachestatsverbose() {
|
||||||
# note, verbose only works on newer ccache, discard the error
|
ccache --version
|
||||||
|
# Verbose only works on newer ccache, discard the error
|
||||||
local got
|
local got
|
||||||
if got="$(ccache --show-stats --verbose 2>/dev/null)"; then
|
if got="$(ccache --show-stats --verbose 2>/dev/null)"; then
|
||||||
echo "$got"
|
echo "$got"
|
||||||
|
|
@ -256,42 +267,44 @@ if [[ $RUNNER_OS == macOS ]]; then
|
||||||
# this works independent of the qt version as there should be only one version installed on the runner at a time
|
# this works independent of the qt version as there should be only one version installed on the runner at a time
|
||||||
export QTDIR
|
export QTDIR
|
||||||
|
|
||||||
if [[ $TARGET_MACOS_VERSION ]]; then
|
if [[ -n $TARGET_MACOS_VERSION ]]; then
|
||||||
# CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla cmake flag needed to compile to target macOS version
|
# CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla CMake flag needed to compile to the target macOS version
|
||||||
flags+=("-DCMAKE_OSX_DEPLOYMENT_TARGET=$TARGET_MACOS_VERSION")
|
flags+=("-DCMAKE_OSX_DEPLOYMENT_TARGET=$TARGET_MACOS_VERSION")
|
||||||
|
|
||||||
# vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version
|
if [[ ${USE_VCPKG:-} == "1" ]]; then
|
||||||
# an easy way is to copy the x64-osx.cmake file and modify it
|
# vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version
|
||||||
triplets_dir="/tmp/cmake/triplets"
|
# An easy way is to copy the x64-osx.cmake file and modify it
|
||||||
triplet_version="custom-triplet"
|
triplets_dir="/tmp/cmake/triplets"
|
||||||
triplet_file="$triplets_dir/$triplet_version.cmake"
|
triplet_version="custom-triplet"
|
||||||
arch=$(uname -m)
|
triplet_file="$triplets_dir/$triplet_version.cmake"
|
||||||
if [[ $arch == x86_64 ]]; then
|
arch=$(uname -m)
|
||||||
arch="x64"
|
if [[ $arch == x86_64 ]]; then
|
||||||
|
arch="x64"
|
||||||
|
fi
|
||||||
|
mkdir -p "$triplets_dir"
|
||||||
|
triplet_source="../vcpkg/triplets/$arch-osx.cmake"
|
||||||
|
if [[ ! -f "$triplet_source" ]]; then
|
||||||
|
triplet_source="../vcpkg/triplets/community/$arch-osx.cmake"
|
||||||
|
fi
|
||||||
|
cp "$triplet_source" "$triplet_file"
|
||||||
|
# The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that
|
||||||
|
# agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode
|
||||||
|
# produce identical binaries when they share the lowest deployment target, so they all
|
||||||
|
# build against 14 to keep one vcpkg cache scope instead of three.
|
||||||
|
# Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml.
|
||||||
|
vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}"
|
||||||
|
echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file"
|
||||||
|
echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file"
|
||||||
|
flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir")
|
||||||
|
flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version")
|
||||||
|
flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version")
|
||||||
|
echo "::group::Generated triplet $triplet_file"
|
||||||
|
cat "$triplet_file"
|
||||||
|
echo "::endgroup::"
|
||||||
fi
|
fi
|
||||||
mkdir -p "$triplets_dir"
|
|
||||||
triplet_source="../vcpkg/triplets/$arch-osx.cmake"
|
|
||||||
if [[ ! -f "$triplet_source" ]]; then
|
|
||||||
triplet_source="../vcpkg/triplets/community/$arch-osx.cmake"
|
|
||||||
fi
|
|
||||||
cp "$triplet_source" "$triplet_file"
|
|
||||||
# The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that
|
|
||||||
# agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode
|
|
||||||
# produce identical binaries when they share the lowest deployment target, so they all
|
|
||||||
# build against 14 to keep one vcpkg cache scope instead of three.
|
|
||||||
# Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml.
|
|
||||||
vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}"
|
|
||||||
echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file"
|
|
||||||
echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file"
|
|
||||||
flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir")
|
|
||||||
flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version")
|
|
||||||
flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version")
|
|
||||||
echo "::group::Generated triplet $triplet_file"
|
|
||||||
cat "$triplet_file"
|
|
||||||
echo "::endgroup::"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "::group::Signing Certificate"
|
echo "::group::Setup signing certificate"
|
||||||
if [[ -n "$MACOS_CERTIFICATE_NAME" ]]; then
|
if [[ -n "$MACOS_CERTIFICATE_NAME" ]]; then
|
||||||
echo "$MACOS_CERTIFICATE" | base64 --decode >"certificate.p12"
|
echo "$MACOS_CERTIFICATE" | base64 --decode >"certificate.p12"
|
||||||
security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
|
security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
|
||||||
|
|
@ -306,9 +319,13 @@ if [[ $RUNNER_OS == macOS ]]; then
|
||||||
fi
|
fi
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
if [[ $MAKE_PACKAGE ]]; then
|
if [[ ${MAKE_PACKAGE:-} == "1" ]]; then
|
||||||
# Workaround https://github.com/actions/runner-images/issues/7522
|
# Workaround https://github.com/actions/runner-images/issues/7522: XProtect holds a
|
||||||
# have hdiutil repeat the command 10 times in hope of success.
|
# freshly written DMG for a moment ("Resource busy"), and a mount that outlived a
|
||||||
|
# failed detach stays busy for the rest of the job. Retry every hdiutil call through
|
||||||
|
# a wrapper that widens the delay so the scan can finish, retries a detach with
|
||||||
|
# -force, and kills a wedged DiskImages helper instead of letting it hold the job
|
||||||
|
# until the 100 minute timeout.
|
||||||
# The wrapper has to exit non-zero once it gives up: CPack reads that status to
|
# The wrapper has to exit non-zero once it gives up: CPack reads that status to
|
||||||
# decide whether the disk image was created, so a zero exit turns a failed hdiutil
|
# decide whether the disk image was created, so a zero exit turns a failed hdiutil
|
||||||
# into a much later, misleading "Problem copying the package" error that hides
|
# into a much later, misleading "Problem copying the package" error that hides
|
||||||
|
|
@ -317,17 +334,74 @@ if [[ $RUNNER_OS == macOS ]]; then
|
||||||
# shellcheck disable=SC2016
|
# shellcheck disable=SC2016
|
||||||
echo '#!/bin/bash
|
echo '#!/bin/bash
|
||||||
attempts=10
|
attempts=10
|
||||||
i=0
|
timeout_seconds=${HDIUTIL_TIMEOUT:-300}
|
||||||
while true; do
|
subcommand=$1
|
||||||
hdiutil "$@" && exit 0
|
# CPACK_DMG_VOLUME_NAME in CMakeLists.txt; a -volname argument wins when there is one.
|
||||||
rc=$?
|
volume_name=Cockatrice
|
||||||
if (( ++i >= attempts )); then
|
arg_count=$#
|
||||||
echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2
|
for (( i = 1; i < arg_count; i++ )); do
|
||||||
exit "$rc"
|
if [[ ${!i} == -volname ]] && (( i + 1 <= arg_count )); then
|
||||||
|
(( i++ ))
|
||||||
|
volume_name=${!i}
|
||||||
fi
|
fi
|
||||||
echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2
|
done
|
||||||
sleep 1
|
|
||||||
done' >"$hdiutil_script"
|
# macOS ships no timeout(1), so a watchdog kills the supervised call. It writes to
|
||||||
|
# /dev/null because a process holding the output pipe open would make CPack wait for
|
||||||
|
# the watchdog instead of for hdiutil; it is killed once the call returns.
|
||||||
|
run_supervised() {
|
||||||
|
"$@" &
|
||||||
|
pid=$!
|
||||||
|
( sleep "$timeout_seconds" && kill -9 "$pid" ) >/dev/null 2>&1 &
|
||||||
|
watchdog=$!
|
||||||
|
wait "$pid"
|
||||||
|
rc=$?
|
||||||
|
kill "$watchdog" 2>/dev/null
|
||||||
|
wait "$watchdog" 2>/dev/null
|
||||||
|
return "$rc"
|
||||||
|
}
|
||||||
|
|
||||||
|
force_detach_stale_mount() {
|
||||||
|
[[ -d /Volumes/$volume_name ]] || return 0
|
||||||
|
echo "hdiutil $subcommand: force-detaching stale /Volumes/$volume_name" >&2
|
||||||
|
run_supervised hdiutil detach -force "/Volumes/$volume_name" >/dev/null 2>&1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
timeouts=0
|
||||||
|
attempt=0
|
||||||
|
delay=1
|
||||||
|
while (( attempt < attempts )); do
|
||||||
|
(( attempt++ ))
|
||||||
|
cmd=(hdiutil "$@")
|
||||||
|
# A busy volume is what a detach fails on, and -force is what clears it.
|
||||||
|
if (( attempt > 1 )) && [[ $subcommand == detach ]]; then
|
||||||
|
cmd=(hdiutil detach -force "${@:2}")
|
||||||
|
fi
|
||||||
|
run_supervised "${cmd[@]}"
|
||||||
|
rc=$?
|
||||||
|
(( rc == 0 )) && exit 0
|
||||||
|
if (( rc == 137 )); then
|
||||||
|
(( timeouts++ ))
|
||||||
|
echo "hdiutil $subcommand attempt $attempt/$attempts timed out after ${timeout_seconds}s" >&2
|
||||||
|
# A wedged DiskImages helper is not the busy-file flake: two kills are all the
|
||||||
|
# evidence the loop needs before failing is a better use of the job budget.
|
||||||
|
if (( timeouts >= 2 )); then
|
||||||
|
echo "::error::hdiutil $subcommand timed out $timeouts times, giving up" >&2
|
||||||
|
exit "$rc"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "hdiutil $subcommand attempt $attempt/$attempts failed (status $rc), retrying" >&2
|
||||||
|
fi
|
||||||
|
(( attempt < attempts )) || break
|
||||||
|
if [[ $subcommand == create || $subcommand == attach ]]; then
|
||||||
|
force_detach_stale_mount
|
||||||
|
fi
|
||||||
|
sleep "$delay"
|
||||||
|
(( delay = delay * 2 > 30 ? 30 : delay * 2 ))
|
||||||
|
done
|
||||||
|
echo "::error::hdiutil $subcommand failed $attempt times (last status $rc), giving up" >&2
|
||||||
|
exit "$rc"' >"$hdiutil_script"
|
||||||
chmod +x "$hdiutil_script"
|
chmod +x "$hdiutil_script"
|
||||||
flags+=(-DCPACK_COMMAND_HDIUTIL="$hdiutil_script")
|
flags+=(-DCPACK_COMMAND_HDIUTIL="$hdiutil_script")
|
||||||
fi
|
fi
|
||||||
|
|
@ -337,7 +411,7 @@ elif [[ $RUNNER_OS == Windows ]]; then
|
||||||
# cmake only hands MSBuild a job count when one is asked for, and without it
|
# cmake only hands MSBuild a job count when one is asked for, and without it
|
||||||
# MSBuild builds a single node: one project at a time, whatever MTT does inside
|
# MSBuild builds a single node: one project at a time, whatever MTT does inside
|
||||||
# it. Ask for a node per core.
|
# it. Ask for a node per core.
|
||||||
if [[ ! $BUILD_PARALLEL_LEVEL ]]; then
|
if [[ -z $BUILD_PARALLEL_LEVEL ]]; then
|
||||||
BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}"
|
BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}"
|
||||||
fi
|
fi
|
||||||
buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL")
|
buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL")
|
||||||
|
|
@ -363,19 +437,20 @@ elif [[ $RUNNER_OS == Windows ]]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $USE_CCACHE ]]; then
|
if [[ ${USE_CCACHE:-} == "1" ]]; then
|
||||||
echo "::group::Show ccache stats"
|
echo "::group::Show ccache stats"
|
||||||
ccachestatsverbose
|
ccachestatsverbose
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "::group::Configure cmake"
|
# Configure CMake
|
||||||
|
echo "::group::Generate build system"
|
||||||
cmake --version
|
cmake --version
|
||||||
echo "Running cmake with flags: ${flags[*]}"
|
echo "Running CMake configuration with the following flags: ${flags[*]}"
|
||||||
cmake .. "${flags[@]}"
|
cmake .. "${flags[@]}"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
if [[ $USE_CCACHE ]]; then
|
if [[ ${USE_CCACHE:-} == "1" ]]; then
|
||||||
# PCH-aware caching is required or ccache refuses to cache any TU that consumes a
|
# PCH-aware caching is required or ccache refuses to cache any TU that consumes a
|
||||||
# precompiled header, silently recompiling everything on every run. The mtime and ctime
|
# precompiled header, silently recompiling everything on every run. The mtime and ctime
|
||||||
# entries cover files created during the build itself: the moc/uic/rcc output and the
|
# entries cover files created during the build itself: the moc/uic/rcc output and the
|
||||||
|
|
@ -390,9 +465,10 @@ if [[ $USE_CCACHE ]]; then
|
||||||
ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime
|
ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Build
|
||||||
echo "::group::Build project"
|
echo "::group::Build project"
|
||||||
cpuinfo
|
cpuinfo
|
||||||
echo "Running cmake --build with flags: ${buildflags[*]}"
|
echo "Running CMake with the following build flags: ${buildflags[*]}"
|
||||||
cmake --build . "${buildflags[@]}"
|
cmake --build . "${buildflags[@]}"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
|
@ -404,16 +480,16 @@ if [[ $BUILD_PROFILE && $RUNNER_OS == Windows && -f msbuild-perf.log ]]; then
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $USE_CCACHE ]]; then
|
if [[ ${USE_CCACHE:-} == "1" ]]; then
|
||||||
if [[ $CCACHE_EVICTION_AGE ]]; then
|
if [[ -n $CCACHE_EVICTION_AGE ]]; then
|
||||||
echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE"
|
echo "::group::Evict ccache files older than $CCACHE_EVICTION_AGE"
|
||||||
ccache --evict-older-than "$CCACHE_EVICTION_AGE"
|
ccache --evict-older-than "$CCACHE_EVICTION_AGE"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
fi
|
fi
|
||||||
echo "::group::Show ccache stats again"
|
echo "::group::Show ccache stats again"
|
||||||
ccachestatsverbose
|
ccachestatsverbose
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
elif [[ $CCACHE_EVICTION_AGE ]]; then
|
elif [[ -n $CCACHE_EVICTION_AGE ]]; then
|
||||||
echo "::error file=$0::ccache eviction is enabled while ccache is disabled!"
|
echo "::error file=$0::ccache eviction is enabled while ccache is disabled!"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -430,9 +506,9 @@ if [[ $RUNNER_OS == macOS ]]; then
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $MAKE_TEST ]]; then
|
if [[ ${MAKE_TEST:-} == "1" ]]; then
|
||||||
echo "::group::Run tests"
|
echo "::group::Run tests"
|
||||||
if [[ $MAKE_SANITIZE ]]; then
|
if [[ ${MAKE_SANITIZE:-} == "1" ]]; then
|
||||||
# detect_leaks=0: Qt and the sanitizers' own runtimes both leak at exit by design, and
|
# detect_leaks=0: Qt and the sanitizers' own runtimes both leak at exit by design, and
|
||||||
# LeakSanitizer's default is aggressive enough to bury our real findings under hundreds
|
# LeakSanitizer's default is aggressive enough to bury our real findings under hundreds
|
||||||
# of process-teardown reports. Malloc/free misuse and use-after-free are what ASan is for
|
# of process-teardown reports. Malloc/free misuse and use-after-free are what ASan is for
|
||||||
|
|
@ -450,11 +526,12 @@ if [[ $MAKE_TEST ]]; then
|
||||||
# bound and -j recovers nearly all of the per-process overhead. The test binaries are safe to
|
# bound and -j recovers nearly all of the per-process overhead. The test binaries are safe to
|
||||||
# run concurrently: the only one that claims a fixed resource is single_instance_manager_test,
|
# run concurrently: the only one that claims a fixed resource is single_instance_manager_test,
|
||||||
# which already scopes its local socket name to its own pid so parallel copies cannot collide.
|
# which already scopes its local socket name to its own pid so parallel copies cannot collide.
|
||||||
|
ctest --version
|
||||||
ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}"
|
ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $MAKE_COVERAGE ]]; then
|
if [[ ${MAKE_COVERAGE:-} == "1" ]]; then
|
||||||
# gcovr is not a build dependency and is not available everywhere, so a missing one is reported
|
# gcovr is not a build dependency and is not available everywhere, so a missing one is reported
|
||||||
# and skipped rather than failing an otherwise green build.
|
# and skipped rather than failing an otherwise green build.
|
||||||
echo "::group::Coverage report"
|
echo "::group::Coverage report"
|
||||||
|
|
@ -479,7 +556,7 @@ if [[ $MAKE_COVERAGE ]]; then
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then
|
if [[ $RUNNER_OS == Windows && ${MAKE_NO_CLIENT:-} != "1" ]]; then
|
||||||
echo "::group::Probe the installer's holder lookup"
|
echo "::group::Probe the installer's holder lookup"
|
||||||
# The working directory is still the build dir here: the package steps below are what
|
# The working directory is still the build dir here: the package steps below are what
|
||||||
# leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name
|
# leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name
|
||||||
|
|
@ -679,18 +756,21 @@ POWERSHELL
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $MAKE_INSTALL ]]; then
|
# Install
|
||||||
|
if [[ ${MAKE_INSTALL:-} == "1" ]]; then
|
||||||
echo "::group::Install"
|
echo "::group::Install"
|
||||||
cmake --build . --target install --config "$BUILDTYPE"
|
cmake --build . --target install --config "$BUILDTYPE"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $MAKE_PACKAGE ]]; then
|
# Package
|
||||||
|
if [[ ${MAKE_PACKAGE:-} == "1" ]]; then
|
||||||
echo "::group::Create package"
|
echo "::group::Create package"
|
||||||
|
cpack --version
|
||||||
cmake --build . --target package --config "$BUILDTYPE"
|
cmake --build . --target package --config "$BUILDTYPE"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
if [[ $PACKAGE_SUFFIX ]]; then
|
if [[ -n $PACKAGE_SUFFIX ]]; then
|
||||||
echo "::group::Update package name"
|
echo "::group::Update package name"
|
||||||
cd ..
|
cd ..
|
||||||
BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX"
|
BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX"
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,11 @@ function RUN ()
|
||||||
if [[ ${WITH_CRASH_REPORTING:-} ]]; then
|
if [[ ${WITH_CRASH_REPORTING:-} ]]; then
|
||||||
args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}")
|
args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}")
|
||||||
fi
|
fi
|
||||||
|
# Same reason: the DWARF level reaches compile.sh only if it is handed to the container,
|
||||||
|
# and without it CI would cache objects twice the size its bounds were measured for.
|
||||||
|
if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then
|
||||||
|
args+=(--env "COCKATRICE_DEBUG_INFO_LEVEL=${COCKATRICE_DEBUG_INFO_LEVEL}")
|
||||||
|
fi
|
||||||
# shellcheck disable=2086
|
# shellcheck disable=2086
|
||||||
docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@"
|
docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@"
|
||||||
return $?
|
return $?
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ Available pre-compiled binaries for installation:
|
||||||
• <kbd>Ubuntu 24.04 LTS</kbd> <sub><i>Noble Numbat</i></sub>
|
• <kbd>Ubuntu 24.04 LTS</kbd> <sub><i>Noble Numbat</i></sub>
|
||||||
• <kbd>Debian 13</kbd> <sub><i>Trixie</i></sub>
|
• <kbd>Debian 13</kbd> <sub><i>Trixie</i></sub>
|
||||||
• <kbd>Debian 12</kbd> <sub><i>Bookworm</i></sub>
|
• <kbd>Debian 12</kbd> <sub><i>Bookworm</i></sub>
|
||||||
|
• <kbd>Fedora 45</kbd>
|
||||||
• <kbd>Fedora 44</kbd>
|
• <kbd>Fedora 44</kbd>
|
||||||
• <kbd>Fedora 43</kbd>
|
|
||||||
|
|
||||||
<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>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>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>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@ build/
|
||||||
.github/
|
.github/
|
||||||
.tx/
|
.tx/
|
||||||
cockatrice/
|
cockatrice/
|
||||||
doc/
|
docs/
|
||||||
oracle/
|
oracle/
|
||||||
Dockerfile
|
Dockerfile
|
||||||
|
|
|
||||||
361
.github/workflows/desktop-build.yml
vendored
|
|
@ -15,7 +15,7 @@ on:
|
||||||
- '!**.md'
|
- '!**.md'
|
||||||
- '!.github/**'
|
- '!.github/**'
|
||||||
- '!.tx/**'
|
- '!.tx/**'
|
||||||
- '!doc/**'
|
- '!docs/**'
|
||||||
- '.github/workflows/desktop-build.yml'
|
- '.github/workflows/desktop-build.yml'
|
||||||
- 'CMakeLists.txt'
|
- 'CMakeLists.txt'
|
||||||
- 'vcpkg.json'
|
- 'vcpkg.json'
|
||||||
|
|
@ -28,7 +28,7 @@ on:
|
||||||
- '!**.md'
|
- '!**.md'
|
||||||
- '!.github/**'
|
- '!.github/**'
|
||||||
- '!.tx/**'
|
- '!.tx/**'
|
||||||
- '!doc/**'
|
- '!docs/**'
|
||||||
- '.github/workflows/desktop-build.yml'
|
- '.github/workflows/desktop-build.yml'
|
||||||
- 'CMakeLists.txt'
|
- 'CMakeLists.txt'
|
||||||
- 'vcpkg.json'
|
- 'vcpkg.json'
|
||||||
|
|
@ -47,6 +47,20 @@ concurrency:
|
||||||
group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})"
|
group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})"
|
||||||
cancel-in-progress: ${{ github.ref_type != 'tag' }}
|
cancel-in-progress: ${{ github.ref_type != 'tag' }}
|
||||||
|
|
||||||
|
# Qt's online installer ships its base package as separate archives. Installing only the
|
||||||
|
# ones Cockatrice loads drops the full 25+ archive download to this handful (Windows Qt
|
||||||
|
# cache: 440 MB → 180 MB, macOS thin Qt: ~200 MB → 60 MB). The addons Cockatrice also needs
|
||||||
|
# (Multimedia, WebSockets, ShaderTools, ImageFormats) are not base archives: they stay in
|
||||||
|
# each leg's qt_modules.
|
||||||
|
#
|
||||||
|
# The list mirrors what cmake/FindQtRuntime.cmake requires, so a component that is missing
|
||||||
|
# here fails configure with "No suitable version of Qt was found" instead of at runtime:
|
||||||
|
# qtbase supplies Core, Gui, Widgets, Network, Sql, Xml, Concurrent and PrintSupport;
|
||||||
|
# qtdeclarative supplies Qml, Quick and QuickWidgets; qtsvg supplies Svg; qttools supplies
|
||||||
|
# Linguist (lrelease/lupdate); qttranslations ships Qt's own catalogs.
|
||||||
|
env:
|
||||||
|
QT_ARCHIVES: qtbase qtdeclarative qtsvg qttools qttranslations
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
configure:
|
configure:
|
||||||
name: Configure
|
name: Configure
|
||||||
|
|
@ -89,7 +103,7 @@ jobs:
|
||||||
TAG: ${{ steps.configure.outputs.tag }}
|
TAG: ${{ steps.configure.outputs.tag }}
|
||||||
run: .ci/prep_release.sh
|
run: .ci/prep_release.sh
|
||||||
|
|
||||||
- name: "Create release"
|
- name: "Create or update draft release"
|
||||||
if: steps.configure.outputs.tag != null
|
if: steps.configure.outputs.tag != null
|
||||||
id: create_release
|
id: create_release
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
@ -101,13 +115,35 @@ jobs:
|
||||||
body_path: ${{ steps.prepare.outputs.body_path }}
|
body_path: ${{ steps.prepare.outputs.body_path }}
|
||||||
prerelease: ${{ steps.prepare.outputs.is_beta }}
|
prerelease: ${{ steps.prepare.outputs.is_beta }}
|
||||||
run: |
|
run: |
|
||||||
args=()
|
# A release for this tag may already exist: a maintainer can save a draft in
|
||||||
[[ $prerelease == yes ]] && args+=(--prerelease)
|
# the GitHub UI before pushing the tag, or an earlier run may have left one.
|
||||||
|
# Never touch a published release, and never overwrite a draft that already
|
||||||
|
# has a body (generated notes or a maintainer's edits) - only fill in an
|
||||||
|
# empty draft. Prints "<isDraft> <body length>"; empty when there is none.
|
||||||
|
release_state="$(gh release view "$tag_name" --json isDraft,body \
|
||||||
|
--template '{{.isDraft}} {{len (or .body "")}}' 2>/dev/null || true)"
|
||||||
|
|
||||||
gh release create "$tag_name" --verify-tag --draft "${args[@]}" \
|
if [[ -z "$release_state" ]]; then
|
||||||
--target "$target" \
|
args=()
|
||||||
--title "$release_name" \
|
[[ $prerelease == yes ]] && args+=(--prerelease)
|
||||||
--notes-file "$body_path"
|
|
||||||
|
gh release create "$tag_name" --verify-tag --draft "${args[@]}" \
|
||||||
|
--target "$target" \
|
||||||
|
--title "$release_name" \
|
||||||
|
--notes-file "$body_path"
|
||||||
|
elif [[ "$release_state" == "true 0" ]]; then
|
||||||
|
echo "Filling in the empty draft release $tag_name"
|
||||||
|
args=(--prerelease=false)
|
||||||
|
[[ $prerelease == yes ]] && args=(--prerelease)
|
||||||
|
|
||||||
|
gh release edit "$tag_name" "${args[@]}" \
|
||||||
|
--title "$release_name" \
|
||||||
|
--notes-file "$body_path"
|
||||||
|
elif [[ "$release_state" == true* ]]; then
|
||||||
|
echo "::warning::Draft release $tag_name already has a body, leaving it untouched"
|
||||||
|
else
|
||||||
|
echo "::warning::Release $tag_name is already published, leaving it untouched"
|
||||||
|
fi
|
||||||
|
|
||||||
vcpkg-baseline:
|
vcpkg-baseline:
|
||||||
name: vcpkg baseline
|
name: vcpkg baseline
|
||||||
|
|
@ -149,7 +185,8 @@ jobs:
|
||||||
|
|
||||||
allow-failure: yes
|
allow-failure: yes
|
||||||
package: skip # We are packaged in Arch already
|
package: skip # We are packaged in Arch already
|
||||||
debug_test: yes # see the note on Fedora 44 below
|
debug_test: yes # see the note on Fedora 45 below
|
||||||
|
ccache_size: 850M # Debug plus the suite: 680 MB working set at -g1
|
||||||
|
|
||||||
- distro: Servatrice_Debian
|
- distro: Servatrice_Debian
|
||||||
version: 12
|
version: 12
|
||||||
|
|
@ -157,46 +194,47 @@ jobs:
|
||||||
package: DEB
|
package: DEB
|
||||||
server_only: yes
|
server_only: yes
|
||||||
test: skip
|
test: skip
|
||||||
ccache_size: 400M # server only, no client to compile
|
# 4.7 min and the server alone: nothing here for a compiler cache to save.
|
||||||
|
persist_ccache: 'false'
|
||||||
|
|
||||||
- distro: Debian
|
- distro: Debian
|
||||||
version: 13
|
version: 13
|
||||||
|
|
||||||
package: DEB
|
package: DEB
|
||||||
ccache_size: 850M # the Release build plus the suite it runs
|
ccache_size: 800M # Release plus the suite: 625 MB working set at -g1
|
||||||
|
|
||||||
|
# The single Fedora leg used to build Debug for the suite and Release for the
|
||||||
|
# package in one job (measured on Fedora 44: 30.8 min, the only leg slower than
|
||||||
|
# Windows), so the package compile waited behind the Debug one. Two legs run in
|
||||||
|
# parallel and each keeps a cache that holds exactly one configuration.
|
||||||
|
#
|
||||||
|
# Arch and Fedora 45 are the two newest toolchains, and the -Wno-error= exceptions
|
||||||
|
# in CMakeLists.txt exist for exactly their warnings. This leg compiles Debug
|
||||||
|
# because only Debug passes -Werror, so it is where a new warning still fails.
|
||||||
|
- distro: Fedora
|
||||||
|
version: 45
|
||||||
|
variant: debug # separate image and ccache from the release leg below
|
||||||
|
|
||||||
|
package: skip
|
||||||
|
debug_test: yes
|
||||||
|
ccache_size: 850M # Debug plus the suite: 660 MB of objects at -g1
|
||||||
|
|
||||||
- distro: Fedora
|
- distro: Fedora
|
||||||
version: 44
|
version: 45
|
||||||
|
variant: release # separate image and ccache from the debug leg above
|
||||||
|
|
||||||
package: RPM
|
package: RPM
|
||||||
# Arch and Fedora 44 are the two newest toolchains, and the -Wno-error= exceptions
|
test: skip # The debug leg above runs this distro's suite
|
||||||
# in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug:
|
ccache_size: 800M # Release plus the package: 614 MB of objects at -g1
|
||||||
# only the Debug configuration passes -Werror, so these two are the legs where a
|
|
||||||
# new warning in either compiler still fails the build.
|
|
||||||
debug_test: yes
|
|
||||||
ccache_size: 900M # Debug for the suite and Release for the package
|
|
||||||
|
|
||||||
- distro: Ubuntu
|
- distro: Ubuntu
|
||||||
version: 26.04
|
version: 26.04
|
||||||
|
|
||||||
package: DEB
|
package: DEB
|
||||||
test: skip # The sanitizer leg below runs this distro's suite
|
test: skip # Debian 13 covers the uninstrumented suite on pull requests
|
||||||
|
# 18.1 min, shorter than the Windows leg. The sanitize leg in the nightly
|
||||||
# The one leg that runs the suite under AddressSanitizer and
|
# matrix runs this distro's instrumented suite.
|
||||||
# UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the
|
persist_ccache: 'false'
|
||||||
# uninstrumented suite, so nothing is lost by instrumenting this one.
|
|
||||||
#
|
|
||||||
# It packages nothing. A sanitized Debug build is not something to ship, and
|
|
||||||
# carrying the package build here meant 18 minutes of Release compile queued
|
|
||||||
# behind the instrumented one in a single job. The leg above publishes this
|
|
||||||
# distro's DEB.
|
|
||||||
- distro: Ubuntu
|
|
||||||
version: 26.04
|
|
||||||
variant: sanitize # separate image and ccache, same distro
|
|
||||||
|
|
||||||
package: skip
|
|
||||||
sanitize: yes
|
|
||||||
ccache_size: 800M # instrumented objects are larger
|
|
||||||
|
|
||||||
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
|
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
|
||||||
needs: configure
|
needs: configure
|
||||||
|
|
@ -211,25 +249,71 @@ jobs:
|
||||||
CCACHE_EVICTION_AGE: 7d
|
CCACHE_EVICTION_AGE: 7d
|
||||||
# The bound is sized against the repository's 10 GB cache quota
|
# The bound is sized against the repository's 10 GB cache quota
|
||||||
# (https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy),
|
# (https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy),
|
||||||
# not against what one leg would like. The quota currently holds about 1.1 GB of
|
# not against what one leg would like. Measured 2026-11-07, the quota holds 2.97 GB
|
||||||
# CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers
|
# of these four ccache entries, 0.92 GB of Qt, 1.24 GB of vcpkg, 0.91 GB of buildkit
|
||||||
# (mode=min in docker-release.yml, added here, brings the last one down to roughly
|
# layers (mode=min in docker-release.yml) and 4.26 GB of CodeQL trap caches: one entry
|
||||||
# 0.3 GB - the one input here that is an expectation rather than a measurement, since
|
# per analyzed commit, about 1.07 GB each, of which every one but the newest is dead.
|
||||||
# mode=min still writes one layer per pushed binary) plus the compiler caches of the nine legs a pull request runs. The five
|
# That is 10.30 GB against the 10 GB quota, so every save past this point costs some
|
||||||
# nightly-only legs keep no compiler cache (see PERSIST_CCACHE below), which is what
|
# other entry, and least-recently-used order reaches the buildkit layers before the
|
||||||
# makes these per-leg bounds affordable. Accounting for all of it: 6.45G of ccache
|
# trap caches. prune-caches below keeps only the newest trap cache; the live total is
|
||||||
# bounds + 3.2G of the fixed entries = 9.65G against the 10G quota.
|
# then 7.1 GB, which is what leaves room for the Windows and macOS compiler caches.
|
||||||
#
|
#
|
||||||
# A configuration needs roughly 500-850 MB on Linux, so the old uniform 600M bound sat
|
# Measured 2026-11-04: one configuration costs 1.2 GB with full DWARF and 660 MB with
|
||||||
# below what a leg writes: ccache spent each build deleting the entries the next run
|
# -g1 (COCKATRICE_DEBUG_INFO_LEVEL below), and the size of the cache decides the hit
|
||||||
# asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each
|
# rate far more sharply than its fill suggests. A 6G cache served 1059 of 1059 calls;
|
||||||
# leg states the size it needs instead: one configuration for most, two for Fedora 44
|
# the 700M bound on the same leg served 14%, because ccache fills to the limit and then
|
||||||
# (Debug for the suite, Release for the package), the server alone for Servatrice.
|
# evicts the entries the next build is about to ask for. A cache that cannot hold a
|
||||||
# Raising these further means caching fewer legs, since GitHub evicts a whole entry
|
# whole configuration therefore buys almost nothing, and the quota goes to fewer legs
|
||||||
# when the total is exceeded.
|
# at a size that holds one:
|
||||||
|
#
|
||||||
|
# Arch Debug + suite 680 MB working set 850M
|
||||||
|
# Debian 13 Release + suite 625 MB 800M
|
||||||
|
# Fedora 45 debug Debug + suite 660 MB 850M
|
||||||
|
# Fedora 45 release Release + package 614 MB 800M
|
||||||
|
# Windows 10 Release + package 890 MB working set 950M
|
||||||
|
# macOS 14/15 Release + package 0.5 GB ccache at -g1 700M
|
||||||
|
# macOS 27 Debug Debug + suite 0.5 GB ccache at -g1 700M
|
||||||
|
#
|
||||||
|
# That is 6.35 GB of bounds; what lands in the quota is 5.15 GB of compiler cache,
|
||||||
|
# 2.97 GB stored by the Linux legs, 0.95 GB by Windows and 1.23 GB by the three macOS
|
||||||
|
# legs, for 9.3 GB of 10 in total.
|
||||||
|
# So only legs worth their share get one. A leg keeps no cache when it duplicates
|
||||||
|
# another leg's coverage (Ubuntu 26.04 behind Debian 13's Release + suite) or finishes
|
||||||
|
# well inside the Windows leg (Servatrice covers the server alone in 4.7 min). The
|
||||||
|
# macOS legs compile the client with a different toolchain (Apple clang), and one
|
||||||
|
# configuration writes only ~0.5 GB, so all three fit beside the Windows entry even
|
||||||
|
# though a macOS cache served 42% and saved about 32s on a 20 min leg. The four Linux
|
||||||
|
# legs that stay compile the client on the toolchains a change can break and are the
|
||||||
|
# ones a cache shortens by the most.
|
||||||
|
# Measured job totals on master 2026-11-04 also show the ceiling this leaves:
|
||||||
|
# Windows 10 is 29.5 min and compiles for 23.7 of it, so Windows sets the
|
||||||
|
# pull-request wall clock no matter how warm these legs are. That is why the Windows
|
||||||
|
# leg keeps the largest bound (see build-vcpkg): MSVC has no -g level to shrink, and
|
||||||
|
# with /Z7 the whole configuration is cacheable, PCH included.
|
||||||
|
#
|
||||||
|
# Steady-state master data corrected this on 2026-11-07: Arch 90.1% and Debian 13
|
||||||
|
# 96.75% per-run hits at the 850M/800M bounds, Fedora 45 debug 99.18% and release
|
||||||
|
# 99.2%. The 75%/82% seen the day before were warming runs - the -g1 switch had
|
||||||
|
# invalidated the cache one commit earlier - not thrashing at the bound. A cold 3G
|
||||||
|
# run on the same day measured one configuration at 680 MB (Arch) and 625 MB
|
||||||
|
# (Debian 13), so those bounds already hold the working set with ~170 MB of slack for
|
||||||
|
# commit-to-commit churn, and raising them buys hit rate the numbers do not show a
|
||||||
|
# need for. The three macOS entries are 700M each because one configuration wrote a
|
||||||
|
# 0.5 GB ccache directory at -g1 and level 0 shrinks it further, so the bound holds the
|
||||||
|
# whole configuration with room to spare.
|
||||||
|
#
|
||||||
|
# Re-add a leg only with a bound that holds its whole configuration: a smaller one is
|
||||||
|
# worse than none.
|
||||||
#
|
#
|
||||||
# Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix.
|
# Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix.
|
||||||
CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }}
|
CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }}
|
||||||
|
# Debug information dominates a cached object: with the built-in flags one
|
||||||
|
# configuration is 1.2 GB and no per-leg bound can hold it, so CI lowers the level to
|
||||||
|
# 1 (file and line, which is what a failing test or a sanitizer backtrace reports) and
|
||||||
|
# one configuration halves. Empty on tags and on the nightly legs, which ship binaries
|
||||||
|
# and keep full DWARF for the Sentry symbols; a tag restores a cache it cannot use and
|
||||||
|
# is slower, which is accepted because a tag is rare and only pull requests gate.
|
||||||
|
COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '1' || '' }}
|
||||||
CMAKE_GENERATOR: 'Ninja'
|
CMAKE_GENERATOR: 'Ninja'
|
||||||
NAME: ${{ matrix.distro }}${{ matrix.version }}
|
NAME: ${{ matrix.distro }}${{ matrix.version }}
|
||||||
IMAGE_SUFFIX: ${{ matrix.variant }}
|
IMAGE_SUFFIX: ${{ matrix.variant }}
|
||||||
|
|
@ -242,11 +326,13 @@ jobs:
|
||||||
# also package, so testing and packaging cost one compile instead of two.
|
# also package, so testing and packaging cost one compile instead of two.
|
||||||
DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }}
|
DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }}
|
||||||
RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }}
|
RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }}
|
||||||
# Whether this leg restores and saves its compiler cache. Every leg a pull request
|
# Whether this leg restores and saves its compiler cache. Not every pull-request leg
|
||||||
# runs keeps one; the nightly-only legs do not, so that the quota is spent on the
|
# keeps one: a cache only pays for itself when its bound holds a whole configuration
|
||||||
# builds that review a change. A tag build runs both sets, and the nightly legs are
|
# (see the sizing comment above), and a leg that duplicates another leg's coverage
|
||||||
# cold there - accepted, because a tag is rare and only the pull-request legs gate.
|
# spends quota without adding any. The nightly-only legs keep none either. Fedora 45
|
||||||
PERSIST_CCACHE: 'true'
|
# states it per leg: its two configurations have their own job and key, so neither
|
||||||
|
# bound is shared with a build whose objects it would evict.
|
||||||
|
PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }}
|
||||||
|
|
||||||
# Shared with build-linux-nightly so the two leg sets run the same steps.
|
# Shared with build-linux-nightly so the two leg sets run the same steps.
|
||||||
steps: &linux-steps
|
steps: &linux-steps
|
||||||
|
|
@ -399,6 +485,14 @@ jobs:
|
||||||
# comes from the client event itself (COCKATRICE_SENTRY_RELEASE).
|
# comes from the client event itself (COCKATRICE_SENTRY_RELEASE).
|
||||||
sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}"
|
sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}"
|
||||||
|
|
||||||
|
# Pull requests and tags only restore; only a push to refs/heads/master writes. A PR save
|
||||||
|
# would replace the objects the next master build restores, and a PR that compiles a
|
||||||
|
# different configuration would evict the warm set for everyone. Tags build with full
|
||||||
|
# DWARF (COCKATRICE_DEBUG_INFO_LEVEL is empty), so a tag's cache would poison the -g1
|
||||||
|
# set if it shared the master scope; it does not, because the save is master-only. The
|
||||||
|
# nightly legs persist nothing at all (PERSIST_CCACHE is false) for the same reason:
|
||||||
|
# a schedule shares refs/heads/master with the push.
|
||||||
|
#
|
||||||
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
|
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
|
||||||
- name: "Delete remote compiler cache (ccache)"
|
- name: "Delete remote compiler cache (ccache)"
|
||||||
if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' && steps.restore_ccache.outputs.cache-hit
|
if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' && steps.restore_ccache.outputs.cache-hit
|
||||||
|
|
@ -466,7 +560,7 @@ jobs:
|
||||||
test: skip # Running tests on all distros is superfluous
|
test: skip # Running tests on all distros is superfluous
|
||||||
|
|
||||||
- distro: Fedora
|
- distro: Fedora
|
||||||
version: 43 # previous release, still covered by the nightly
|
version: 44 # previous release, still covered by the nightly
|
||||||
|
|
||||||
package: RPM
|
package: RPM
|
||||||
test: skip # Running tests on all distros is superfluous
|
test: skip # Running tests on all distros is superfluous
|
||||||
|
|
@ -477,6 +571,28 @@ jobs:
|
||||||
package: DEB
|
package: DEB
|
||||||
test: skip # Running tests on all distros is superfluous
|
test: skip # Running tests on all distros is superfluous
|
||||||
|
|
||||||
|
# The one leg that runs the suite under AddressSanitizer and
|
||||||
|
# UndefinedBehaviorSanitizer. It runs nightly rather than on pull requests: it is
|
||||||
|
# not on the pull-request critical path (Windows sets that), and its 1.2G cache
|
||||||
|
# was the largest single entry in the 10 GB quota. Debian 13 and Fedora 45 keep
|
||||||
|
# covering the uninstrumented suite on pull requests, so nothing is lost there.
|
||||||
|
#
|
||||||
|
# It packages nothing. A sanitized Debug build is not something to ship; the
|
||||||
|
# pull-request Ubuntu 26.04 leg publishes this distro's DEB.
|
||||||
|
- distro: Ubuntu
|
||||||
|
version: 26.04
|
||||||
|
variant: sanitize # separate image, same distro as the coverage leg below
|
||||||
|
|
||||||
|
package: skip
|
||||||
|
sanitize: yes
|
||||||
|
# A sanitizer finding must not redden the nightly run: vcpkg-cache-import only
|
||||||
|
# imports the archives of a run whose conclusion is success. The job itself
|
||||||
|
# still reports failure.
|
||||||
|
allow-failure: yes
|
||||||
|
# Scratch only (nightly legs persist no cache); sized so the instrumented
|
||||||
|
# objects, which are larger than the 614 MB measured, do not thrash mid-build.
|
||||||
|
ccache_size: 1200M
|
||||||
|
|
||||||
# The one leg that runs instrumented for line coverage and publishes the report as
|
# The one leg that runs instrumented for line coverage and publishes the report as
|
||||||
# a workflow artifact. It stays a separate leg rather than a second flag on the
|
# a workflow artifact. It stays a separate leg rather than a second flag on the
|
||||||
# sanitizer leg because the two instruments answer different questions: the
|
# sanitizer leg because the two instruments answer different questions: the
|
||||||
|
|
@ -501,7 +617,7 @@ jobs:
|
||||||
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
|
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
|
||||||
needs: configure
|
needs: configure
|
||||||
# A nightly and a tag build cover the legs a pull request or a merge does not wait for:
|
# A nightly and a tag build cover the legs a pull request or a merge does not wait for:
|
||||||
# the previous release of each distro, and the one instrument that only reports.
|
# the previous release of each distro, and the instrumented legs.
|
||||||
if: github.event_name == 'schedule' || github.ref_type == 'tag'
|
if: github.event_name == 'schedule' || github.ref_type == 'tag'
|
||||||
runs-on: ubuntu-latest # https://github.com/actions/runner-images
|
runs-on: ubuntu-latest # https://github.com/actions/runner-images
|
||||||
continue-on-error: ${{ matrix.allow-failure == 'yes' }}
|
continue-on-error: ${{ matrix.allow-failure == 'yes' }}
|
||||||
|
|
@ -514,10 +630,16 @@ jobs:
|
||||||
# A cold nightly build costs minutes on a runner nobody waits for; a cold pull request
|
# A cold nightly build costs minutes on a runner nobody waits for; a cold pull request
|
||||||
# build costs a review. CCACHE_SIZE is still passed so ccache trims its scratch dir.
|
# build costs a review. CCACHE_SIZE is still passed so ccache trims its scratch dir.
|
||||||
PERSIST_CCACHE: 'false'
|
PERSIST_CCACHE: 'false'
|
||||||
CCACHE_SIZE: 600M
|
CCACHE_SIZE: ${{ matrix.ccache_size || '600M' }}
|
||||||
CMAKE_GENERATOR: 'Ninja'
|
CMAKE_GENERATOR: 'Ninja'
|
||||||
NAME: ${{ matrix.distro }}${{ matrix.version }}
|
NAME: ${{ matrix.distro }}${{ matrix.version }}
|
||||||
IMAGE_SUFFIX: ${{ matrix.variant }}
|
IMAGE_SUFFIX: ${{ matrix.variant }}
|
||||||
|
# The shared steps below gate on these, so they have to be derived here too. Left
|
||||||
|
# undefined they silently skipped the build the leg exists for: the coverage leg
|
||||||
|
# found build/ empty and its report collection failed on every nightly and tag run.
|
||||||
|
# Same derivation as the pull request job above.
|
||||||
|
DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }}
|
||||||
|
RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }}
|
||||||
steps: *linux-steps
|
steps: *linux-steps
|
||||||
|
|
||||||
build-vcpkg:
|
build-vcpkg:
|
||||||
|
|
@ -530,6 +652,7 @@ jobs:
|
||||||
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
|
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
|
||||||
|
|
||||||
ccache_eviction_age: 7d
|
ccache_eviction_age: 7d
|
||||||
|
ccache_size: 700M # Release + package: ~0.4 GB at level 0
|
||||||
cmake_generator: Ninja
|
cmake_generator: Ninja
|
||||||
make_package: 1
|
make_package: 1
|
||||||
override_target: 14
|
override_target: 14
|
||||||
|
|
@ -548,6 +671,7 @@ jobs:
|
||||||
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
|
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
|
||||||
|
|
||||||
ccache_eviction_age: 7d
|
ccache_eviction_age: 7d
|
||||||
|
ccache_size: 700M # Release + package: ~0.4 GB at level 0
|
||||||
cmake_generator: Ninja
|
cmake_generator: Ninja
|
||||||
make_package: 1
|
make_package: 1
|
||||||
override_target: 15
|
override_target: 15
|
||||||
|
|
@ -565,6 +689,7 @@ jobs:
|
||||||
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
|
runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md
|
||||||
|
|
||||||
ccache_eviction_age: 7d
|
ccache_eviction_age: 7d
|
||||||
|
ccache_size: 700M # Debug + suite: ~0.5 GB at level 0
|
||||||
cmake_generator: Ninja
|
cmake_generator: Ninja
|
||||||
qt_version: 6.11.*
|
qt_version: 6.11.*
|
||||||
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
||||||
|
|
@ -589,6 +714,9 @@ jobs:
|
||||||
qt_version: 6.11.*
|
qt_version: 6.11.*
|
||||||
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
||||||
type: Release
|
type: Release
|
||||||
|
ccache_eviction_age: 7d
|
||||||
|
ccache_size: 950M # Release plus the package: 890 MB working set without /Z7
|
||||||
|
use_ccache: 1
|
||||||
# Scope is named after the vs2026 toolchain, not the generator: the
|
# Scope is named after the vs2026 toolchain, not the generator: the
|
||||||
# ABI hash covers cl.exe, which is identical, so this leg keeps
|
# ABI hash covers cl.exe, which is identical, so this leg keeps
|
||||||
# restoring the warm archives. Renaming would orphan them.
|
# restoring the warm archives. Renaming would orphan them.
|
||||||
|
|
@ -602,14 +730,21 @@ jobs:
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
timeout-minutes: 100
|
timeout-minutes: 100
|
||||||
env:
|
env:
|
||||||
|
# The Windows leg and all three pull-request macOS legs keep a compiler cache. ccache
|
||||||
|
# drives MSVC as long as the compiler emits /Z7, which CMakeLists.txt sets for exactly
|
||||||
|
# this reason, and it drives clang on macOS the same way; both cache the PCH-backed
|
||||||
|
# targets with the sloppiness that .ci/compile.sh re-asserts. Every leg builds without
|
||||||
|
# debug information on non-tag refs, so one macOS configuration writes ~0.4-0.5 GB and
|
||||||
|
# the 700M bound holds it with room for commit-to-commit churn (see build-linux for the
|
||||||
|
# quota arithmetic).
|
||||||
CCACHE_DIR: ${{ github.workspace }}/.cache/
|
CCACHE_DIR: ${{ github.workspace }}/.cache/
|
||||||
# macOS configurations compile about twice the TUs of a Linux one, so each of these
|
CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }}
|
||||||
# three legs gets a bound above what it has been measured to write. The four macOS
|
PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }}
|
||||||
# legs used to be bounded at 600M from the same quota as the Linux ones; the three
|
# MSVC has no -g level, so there is nothing to ask for on Windows: level 0 is the one
|
||||||
# pull-request legs now get 700M each while the nightly Intel leg keeps none, and
|
# value with a meaning there, and it drops /Z7 rather than lowering a level. Pull-request
|
||||||
# that is what fits (see CCACHE_SIZE in build-linux for the full arithmetic).
|
# builds carry no debug information, which is most of what a compiler cache stores; a
|
||||||
CCACHE_SIZE: 700M
|
# tag is the build whose binary is shipped and symbolicated, so it keeps the full set.
|
||||||
PERSIST_CCACHE: 'true'
|
COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '0' || '' }}
|
||||||
|
|
||||||
# Shared with build-vcpkg-nightly so the two leg sets run the same steps.
|
# Shared with build-vcpkg-nightly so the two leg sets run the same steps.
|
||||||
steps: &vcpkg-steps
|
steps: &vcpkg-steps
|
||||||
|
|
@ -624,18 +759,31 @@ jobs:
|
||||||
with:
|
with:
|
||||||
msbuild-architecture: x64
|
msbuild-architecture: x64
|
||||||
|
|
||||||
|
- name: "[Windows] Setup ccache"
|
||||||
|
if: matrix.os == 'Windows' && matrix.use_ccache == 1
|
||||||
|
shell: bash
|
||||||
|
run: choco install ccache --no-progress -y
|
||||||
|
|
||||||
- name: "[macOS] Setup ccache"
|
- name: "[macOS] Setup ccache"
|
||||||
if: matrix.os == 'macOS' && matrix.use_ccache == 1
|
if: matrix.os == 'macOS' && matrix.use_ccache == 1
|
||||||
run: brew install ccache
|
run: brew install ccache
|
||||||
|
|
||||||
- name: "[macOS] Restore compiler cache (ccache)"
|
- name: "Restore compiler cache (ccache)"
|
||||||
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true'
|
if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true'
|
||||||
id: restore_ccache
|
id: restore_ccache
|
||||||
uses: actions/cache/restore@v6
|
uses: actions/cache/restore@v6
|
||||||
with:
|
with:
|
||||||
key: ccache-${{ matrix.os }}${{ matrix.override_target }}-${{ matrix.runner }}
|
key: ccache-${{ matrix.os }}${{ matrix.target }}
|
||||||
path: ${{ env.CCACHE_DIR }}
|
path: ${{ env.CCACHE_DIR }}
|
||||||
|
|
||||||
|
# Every macOS leg that keeps a cache restores the master entry for its own target.
|
||||||
|
# Measured on master 2026-11-07 (before the debug-information change), one macOS
|
||||||
|
# configuration wrote a 0.5 GB ccache directory at -g1 against the 700M bound, and the
|
||||||
|
# restored cache served 42% of calls. Level 0 shrinks that directory to ~0.4 GB, so the
|
||||||
|
# bound holds the whole configuration and the three entries fit the quota (see the
|
||||||
|
# sizing note in build-linux). macOS 14 and 15 build the same Release + package
|
||||||
|
# configuration for two deployment targets, but each is cheap enough to keep its own.
|
||||||
|
|
||||||
- name: "[macOS] Install aqtinstall"
|
- name: "[macOS] Install aqtinstall"
|
||||||
if: matrix.os == 'macOS'
|
if: matrix.os == 'macOS'
|
||||||
run: pipx install aqtinstall
|
run: pipx install aqtinstall
|
||||||
|
|
@ -654,7 +802,7 @@ jobs:
|
||||||
id: restore_qt
|
id: restore_qt
|
||||||
uses: actions/cache/restore@v6
|
uses: actions/cache/restore@v6
|
||||||
with:
|
with:
|
||||||
key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-thin
|
key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-${{ env.QT_ARCHIVES }}-thin
|
||||||
path: ${{ github.workspace }}/Qt
|
path: ${{ github.workspace }}/Qt
|
||||||
|
|
||||||
# Using jurplel/install-qt-action to install Qt without using brew
|
# Using jurplel/install-qt-action to install Qt without using brew
|
||||||
|
|
@ -663,6 +811,7 @@ jobs:
|
||||||
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
|
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
|
||||||
uses: jurplel/install-qt-action@v4
|
uses: jurplel/install-qt-action@v4
|
||||||
with:
|
with:
|
||||||
|
archives: ${{ env.QT_ARCHIVES }}
|
||||||
cache: false
|
cache: false
|
||||||
# cache-key-prefix: Qt
|
# cache-key-prefix: Qt
|
||||||
dir: ${{ github.workspace }} # thinning script depends on this location
|
dir: ${{ github.workspace }} # thinning script depends on this location
|
||||||
|
|
@ -686,6 +835,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
# Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released
|
# 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
|
aqtsource: git+https://github.com/miurahr/aqtinstall.git
|
||||||
|
archives: ${{ env.QT_ARCHIVES }}
|
||||||
cache: ${{ github.ref == 'refs/heads/master' }}
|
cache: ${{ github.ref == 'refs/heads/master' }}
|
||||||
cache-key-prefix: Qt
|
cache-key-prefix: Qt
|
||||||
modules: ${{ matrix.qt_modules }}
|
modules: ${{ matrix.qt_modules }}
|
||||||
|
|
@ -694,7 +844,7 @@ jobs:
|
||||||
- name: "[Windows] Install NSIS"
|
- name: "[Windows] Install NSIS"
|
||||||
if: matrix.os == 'Windows'
|
if: matrix.os == 'Windows'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: choco install nsis
|
run: choco install nsis --no-progress
|
||||||
|
|
||||||
# Ninja invokes cl.exe directly and needs the MSVC environment on PATH.
|
# Ninja invokes cl.exe directly and needs the MSVC environment on PATH.
|
||||||
- name: "[Windows] Set up MSVC environment"
|
- name: "[Windows] Set up MSVC environment"
|
||||||
|
|
@ -774,6 +924,21 @@ jobs:
|
||||||
WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }}
|
WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }}
|
||||||
run: .ci/compile.sh --server --test --vcpkg
|
run: .ci/compile.sh --server --test --vcpkg
|
||||||
|
|
||||||
|
# What the compiler cache has to hold for this leg. ccache stores the objects it
|
||||||
|
# compiles, so their total is the working set a bound has to fit, and the cache
|
||||||
|
# directory shows what this build actually wrote. Debug information dominates both,
|
||||||
|
# which is why the level above is reported next to them.
|
||||||
|
- name: "Report compiler cache footprint"
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "=== ccache directory"
|
||||||
|
du -sh "$CCACHE_DIR" 2>/dev/null || echo "(none)"
|
||||||
|
echo "=== objects and archives"
|
||||||
|
find build -type f \( -name '*.o' -o -name '*.obj' -o -name '*.a' -o -name '*.lib' \) -exec du -k {} + 2>/dev/null \
|
||||||
|
| awk '{n++; s+=$1} END {printf "%d files, %.2f GB\n", n, s/1048576}'
|
||||||
|
echo "=== debug info level in use: '${COCKATRICE_DEBUG_INFO_LEVEL}'"
|
||||||
|
|
||||||
- name: "Install sentry-cli"
|
- name: "Install sentry-cli"
|
||||||
if: needs.configure.outputs.tag != null && matrix.make_package == '1'
|
if: needs.configure.outputs.tag != null && matrix.make_package == '1'
|
||||||
# Same as the Linux leg above: the install downloads from Sentry's registry, so a
|
# Same as the Linux leg above: the install downloads from Sentry's registry, so a
|
||||||
|
|
@ -958,9 +1123,11 @@ jobs:
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
|
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
|
||||||
- name: "[macOS] Delete remote compiler cache (ccache)"
|
- name: "Delete remote compiler cache (ccache)"
|
||||||
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
|
if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
# Bash, not the Windows runner's default pwsh, which cannot parse this `if`.
|
||||||
|
shell: bash
|
||||||
env:
|
env:
|
||||||
CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }}
|
CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
|
@ -969,8 +1136,8 @@ jobs:
|
||||||
echo "Cache deleted successfully"
|
echo "Cache deleted successfully"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: "[macOS] Cache updated compiler cache (ccache)"
|
- name: "Cache updated compiler cache (ccache)"
|
||||||
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master'
|
if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master'
|
||||||
uses: actions/cache/save@v6
|
uses: actions/cache/save@v6
|
||||||
with:
|
with:
|
||||||
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
|
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
|
||||||
|
|
@ -1096,7 +1263,6 @@ jobs:
|
||||||
runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md
|
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
|
# 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
|
cmake_generator: Ninja
|
||||||
make_package: 1
|
make_package: 1
|
||||||
override_target: 13
|
override_target: 13
|
||||||
|
|
@ -1105,7 +1271,6 @@ jobs:
|
||||||
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
||||||
soc: Intel
|
soc: Intel
|
||||||
type: Release
|
type: Release
|
||||||
use_ccache: 1
|
|
||||||
vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/'
|
vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/'
|
||||||
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
|
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
|
||||||
|
|
||||||
|
|
@ -1116,12 +1281,6 @@ jobs:
|
||||||
if: github.event_name == 'schedule' || github.ref_type == 'tag'
|
if: github.event_name == 'schedule' || github.ref_type == 'tag'
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
timeout-minutes: 100
|
timeout-minutes: 100
|
||||||
env:
|
|
||||||
CCACHE_DIR: ${{ github.workspace }}/.cache/
|
|
||||||
# Like build-linux-nightly: this leg keeps no compiler cache, so the quota stays with
|
|
||||||
# the pull-request legs (see PERSIST_CCACHE there).
|
|
||||||
PERSIST_CCACHE: 'false'
|
|
||||||
CCACHE_SIZE: 600M
|
|
||||||
steps: *vcpkg-steps
|
steps: *vcpkg-steps
|
||||||
|
|
||||||
prune-caches:
|
prune-caches:
|
||||||
|
|
@ -1149,6 +1308,16 @@ jobs:
|
||||||
# age is the right test. Dropping a blob an index still lists costs the next image build
|
# age is the right test. Dropping a blob an index still lists costs the next image build
|
||||||
# one rebuilt layer; docker-release gates nothing, so that is cheaper than keeping the
|
# one rebuilt layer; docker-release gates nothing, so that is cheaper than keeping the
|
||||||
# entries forever.
|
# entries forever.
|
||||||
|
#
|
||||||
|
# codeql.yml's analyze job keeps its extracted database as one entry per analyzed commit,
|
||||||
|
# keyed codeql-trap-<schema>-<version>-<language>-<sha>, about 1.07 GB each. Each run
|
||||||
|
# restores the newest entry by prefix and saves its own, so every run that analyzes a new
|
||||||
|
# commit makes the previous entry dead, and the action never deletes them: four had
|
||||||
|
# accumulated by 2026-11-07 (4.26 GB, 40% of the quota). Retiring them cannot wait for
|
||||||
|
# the age rule, because they are not the oldest entries and so not what least-recently-
|
||||||
|
# used eviction reaches: the buildkit layers are. Only the newest can be restored again,
|
||||||
|
# and a miss costs one re-extraction in a job that reports rather than gates, so keeping
|
||||||
|
# exactly that one is safe.
|
||||||
- name: "Delete unusable cache entries"
|
- name: "Delete unusable cache entries"
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
|
|
@ -1159,9 +1328,10 @@ jobs:
|
||||||
LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/'
|
LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/'
|
||||||
# Must list the ccache key of every leg that keeps a compiler cache
|
# Must list the ccache key of every leg that keeps a compiler cache
|
||||||
# (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted.
|
# (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted.
|
||||||
# The nightly legs keep none, so their leftover entries are reclaimed here
|
# The nightly legs and the pull-request legs that finish under the Windows leg
|
||||||
# instead of waiting out the age rule.
|
# keep none, so their leftover entries are reclaimed here instead of waiting out
|
||||||
LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27'
|
# the age rule.
|
||||||
|
LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Windows10 ccache-macOS14 ccache-macOS15 ccache-macOS27'
|
||||||
run: |
|
run: |
|
||||||
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \
|
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \
|
||||||
--jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv"
|
--jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv"
|
||||||
|
|
@ -1169,15 +1339,19 @@ jobs:
|
||||||
cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))"
|
cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))"
|
||||||
live=" ${LIVE_VCPKG_SCOPES} "
|
live=" ${LIVE_VCPKG_SCOPES} "
|
||||||
live_ccache=" ${LIVE_CCACHE_KEYS} "
|
live_ccache=" ${LIVE_CCACHE_KEYS} "
|
||||||
|
# The one trap cache a later run can still restore, by prefix. ISO-8601 sorts
|
||||||
|
# lexicographically, so this is a plain string compare. Entries scoped to another
|
||||||
|
# ref are deleted by the ref rule below whatever this says.
|
||||||
|
newest_trap="$(awk -F'\t' '$2 == "refs/heads/master" && $3 ~ /^codeql-trap-/ && $4 > m { m = $4; k = $3 } END { print k }' "$RUNNER_TEMP/caches.tsv")"
|
||||||
|
|
||||||
deleted=0
|
deleted=0
|
||||||
while IFS=$'\t' read -r id ref key accessed; do
|
while IFS=$'\t' read -r id ref key accessed; do
|
||||||
[[ -n $id ]] || continue
|
[[ -n $id ]] || continue
|
||||||
# Only ever touch the key shapes our workflows create (buildkit-* and index-*
|
# Only ever touch the key shapes our workflows create (buildkit-* and index-*
|
||||||
# come from docker-release.yml), so that an entry of some other workflow can
|
# come from docker-release.yml, codeql-trap-* from codeql-action in codeql.yml),
|
||||||
# never be deleted by accident
|
# so that an entry of some other workflow can never be deleted by accident
|
||||||
case "$key" in
|
case "$key" in
|
||||||
vcpkg/* | ccache-* | Qt* | buildkit-* | index-*) ;;
|
vcpkg/* | ccache-* | Qt* | buildkit-* | index-* | codeql-trap-*) ;;
|
||||||
*) continue ;;
|
*) continue ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
|
@ -1185,8 +1359,9 @@ jobs:
|
||||||
if [[ $ref != refs/heads/master ]]; then
|
if [[ $ref != refs/heads/master ]]; then
|
||||||
reason="scoped to $ref"
|
reason="scoped to $ref"
|
||||||
elif [[ $key == ccache-* ]]; then
|
elif [[ $key == ccache-* ]]; then
|
||||||
# An exact token compare: ccache-Ubuntu26.04 is a prefix of
|
# An exact token compare, because one key can be a prefix of another:
|
||||||
# ccache-Ubuntu26.04sanitize, and both keys are live
|
# ccache-Fedora45 is a prefix of ccache-Fedora45debug and
|
||||||
|
# ccache-Fedora45release, and only the latter two are live.
|
||||||
if [[ $live_ccache != *" $key "* ]]; then
|
if [[ $live_ccache != *" $key "* ]]; then
|
||||||
reason="no pull-request leg keeps it"
|
reason="no pull-request leg keeps it"
|
||||||
fi
|
fi
|
||||||
|
|
@ -1197,6 +1372,10 @@ jobs:
|
||||||
if [[ $live != *" $scope "* ]]; then
|
if [[ $live != *" $scope "* ]]; then
|
||||||
reason="no matrix leg uses $scope"
|
reason="no matrix leg uses $scope"
|
||||||
fi
|
fi
|
||||||
|
elif [[ $key == codeql-trap-* ]]; then
|
||||||
|
if [[ $key != "$newest_trap" ]]; then
|
||||||
|
reason="superseded by the newest trap cache"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
if [[ -z $reason ]]; then
|
if [[ -z $reason ]]; then
|
||||||
stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)"
|
stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)"
|
||||||
|
|
|
||||||
2
.github/workflows/desktop-lint.yml
vendored
|
|
@ -12,7 +12,7 @@ on:
|
||||||
- '!.ci/**'
|
- '!.ci/**'
|
||||||
- '!.github/**'
|
- '!.github/**'
|
||||||
- '!.tx/**'
|
- '!.tx/**'
|
||||||
- '!doc/**'
|
- '!docs/**'
|
||||||
- '.ci/lint_cpp.sh'
|
- '.ci/lint_cpp.sh'
|
||||||
- '.github/workflows/desktop-lint.yml'
|
- '.github/workflows/desktop-lint.yml'
|
||||||
- '.clang-format'
|
- '.clang-format'
|
||||||
|
|
|
||||||
4
.github/workflows/documentation-build.yml
vendored
|
|
@ -6,7 +6,7 @@ permissions:
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'doc/doxygen/**'
|
- 'docs/**'
|
||||||
- '.github/workflows/documentation-build.yml'
|
- '.github/workflows/documentation-build.yml'
|
||||||
- 'Doxyfile'
|
- 'Doxyfile'
|
||||||
release:
|
release:
|
||||||
|
|
@ -71,7 +71,7 @@ jobs:
|
||||||
destination_dir: docs # docs will be available at https://cockatrice.github.io/docs/
|
destination_dir: docs # docs will be available at https://cockatrice.github.io/docs/
|
||||||
external_repository: Cockatrice/cockatrice.github.io
|
external_repository: Cockatrice/cockatrice.github.io
|
||||||
publish_branch: master
|
publish_branch: master
|
||||||
publish_dir: ./docs/html
|
publish_dir: ./docs-output/html
|
||||||
|
|
||||||
- name: Link to Documentation Page
|
- name: Link to Documentation Page
|
||||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||||
|
|
|
||||||
2
.gitignore
vendored
|
|
@ -15,4 +15,4 @@ compile_commands.json
|
||||||
.cache
|
.cache
|
||||||
.gdb_history
|
.gdb_history
|
||||||
cockatrice/resources/config/qtlogging.ini
|
cockatrice/resources/config/qtlogging.ini
|
||||||
docs/
|
docs-output/
|
||||||
|
|
|
||||||
2
.gitmodules
vendored
|
|
@ -2,7 +2,7 @@
|
||||||
path = vcpkg
|
path = vcpkg
|
||||||
url = https://github.com/microsoft/vcpkg.git
|
url = https://github.com/microsoft/vcpkg.git
|
||||||
[submodule "doxygen-awesome-css"]
|
[submodule "doxygen-awesome-css"]
|
||||||
path = doc/doxygen/theme
|
path = docs/doxygen/theme
|
||||||
url = https://github.com/jothepro/doxygen-awesome-css.git
|
url = https://github.com/jothepro/doxygen-awesome-css.git
|
||||||
[submodule "external/qttesting"]
|
[submodule "external/qttesting"]
|
||||||
path = external/qttesting
|
path = external/qttesting
|
||||||
|
|
|
||||||
198
CMakeLists.txt
|
|
@ -1,11 +1,11 @@
|
||||||
# Cockatrice's main CMakeLists.txt
|
# Cockatrice's main CMakeLists.txt
|
||||||
#
|
#
|
||||||
# This is basically a wrapper to enable/disable the compilation
|
# This is basically a wrapper to enable/disable the compilation of the different projects:
|
||||||
# of the different projects: servatrice, cockatrice, test
|
# Cockatrice, Oracle, Servatrice, Test
|
||||||
# This file sets all the variables shared between the projects
|
# This file sets all the variables shared between the projects
|
||||||
# like the installation path, compilation flags etc..
|
# like the installation path, compilation flags etc..
|
||||||
|
|
||||||
# 3.16 required for Qt6 and target_precompile_headers()
|
# CMake 3.16 is required for Qt6 and target_precompile_headers()
|
||||||
cmake_minimum_required(VERSION 3.16)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
|
|
||||||
# Use compiler cache (ccache)
|
# Use compiler cache (ccache)
|
||||||
|
|
@ -40,24 +40,20 @@ if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Default to "Release" build type
|
# Default to "Release" build type
|
||||||
# User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call
|
# A user-provided CMAKE_BUILD_TYPE must be checked before the PROJECT() call
|
||||||
if(DEFINED CMAKE_BUILD_TYPE)
|
if(NOT CMAKE_BUILD_TYPE)
|
||||||
set(CMAKE_BUILD_TYPE
|
|
||||||
${CMAKE_BUILD_TYPE}
|
|
||||||
CACHE STRING "Type of build"
|
|
||||||
)
|
|
||||||
else()
|
|
||||||
set(CMAKE_BUILD_TYPE
|
set(CMAKE_BUILD_TYPE
|
||||||
Release
|
Release
|
||||||
CACHE STRING "Type of build"
|
CACHE STRING "Build type"
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# ccache does not support MSVC and must not auto-engage on Windows
|
# ccache drives MSVC too (since 4.7), but only when the compiler writes debug
|
||||||
# (it is installed unintentionally on the Windows CI runner).
|
# information into the object file: /Zi and /ZI keep it in a shared PDB, and ccache
|
||||||
# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows
|
# reports them as unsupported rather than risk a stale one. The MSVC flags below
|
||||||
# also opts out of ccache even though the GNUCXX branch below supports it.
|
# therefore use /Z7. MSVC precompiled headers are cached as well, with the same
|
||||||
if(USE_CCACHE AND NOT WIN32)
|
# sloppiness the other toolchains need.
|
||||||
|
if(USE_CCACHE)
|
||||||
find_program(CCACHE_PROGRAM ccache)
|
find_program(CCACHE_PROGRAM ccache)
|
||||||
if(CCACHE_PROGRAM)
|
if(CCACHE_PROGRAM)
|
||||||
# Support Unix Makefiles and Ninja
|
# Support Unix Makefiles and Ninja
|
||||||
|
|
@ -71,10 +67,10 @@ if(USE_CCACHE AND NOT WIN32)
|
||||||
# check for every project on the machine, not just this one.
|
# check for every project on the machine, not just this one.
|
||||||
execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
|
execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
|
||||||
message(STATUS "Found CCache ${CCACHE_PROGRAM}")
|
message(STATUS "Found CCache ${CCACHE_PROGRAM}")
|
||||||
|
else()
|
||||||
|
# An explicit opt-in must not disappear silently.
|
||||||
|
message(STATUS "ccache requested but not found; building without a compiler cache")
|
||||||
endif()
|
endif()
|
||||||
elseif(USE_CCACHE AND WIN32)
|
|
||||||
# An explicit opt-in must not disappear silently on Windows.
|
|
||||||
message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows")
|
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if(WIN32 OR USE_VCPKG)
|
if(WIN32 OR USE_VCPKG)
|
||||||
|
|
@ -108,20 +104,21 @@ endif()
|
||||||
# Version can be overriden by git tags, see cmake/getversion.cmake
|
# Version can be overriden by git tags, see cmake/getversion.cmake
|
||||||
project("Cockatrice" VERSION 3.2.0)
|
project("Cockatrice" VERSION 3.2.0)
|
||||||
|
|
||||||
# Set release name if not provided via env/cmake var
|
# Set release name if not provided via ENV/CMake var
|
||||||
if(NOT DEFINED GIT_TAG_RELEASENAME)
|
if(NOT DEFINED GIT_TAG_RELEASENAME)
|
||||||
set(GIT_TAG_RELEASENAME "Reality Check")
|
set(GIT_TAG_RELEASENAME "Reality Check")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Use c++20 for all targets
|
# Use C++20 for all targets
|
||||||
set(CMAKE_CXX_STANDARD
|
set(CMAKE_CXX_STANDARD
|
||||||
20
|
20
|
||||||
CACHE STRING "C++ ISO Standard"
|
CACHE STRING "C++ ISO Standard"
|
||||||
)
|
)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED True)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
# Set conventional loops
|
# Set conventional loops
|
||||||
set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS true)
|
set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS ON)
|
||||||
|
|
||||||
# Search path for cmake modules
|
# Search path for cmake modules
|
||||||
set(COCKATRICE_CMAKE_PATH "${PROJECT_SOURCE_DIR}/cmake")
|
set(COCKATRICE_CMAKE_PATH "${PROJECT_SOURCE_DIR}/cmake")
|
||||||
|
|
@ -134,31 +131,31 @@ include(createversionfile)
|
||||||
|
|
||||||
# Define a proper install path
|
# Define a proper install path
|
||||||
if(UNIX)
|
if(UNIX)
|
||||||
if(APPLE)
|
if(APPLE) # macOS
|
||||||
# macOS
|
# Due to the special bundle structure ignore the prefix eventually set by the user
|
||||||
# Due to the special bundle structure ignore
|
|
||||||
# the prefix eventually set by the user.
|
|
||||||
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/release)
|
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/release)
|
||||||
|
|
||||||
# Force ccache usage if available
|
# Force ccache usage if requested and available
|
||||||
get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE)
|
if(USE_CCACHE)
|
||||||
if(RULE_LAUNCH_COMPILE)
|
get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE)
|
||||||
message(STATUS "Force enabling CCache usage under macOS")
|
if(RULE_LAUNCH_COMPILE)
|
||||||
# Set up wrapper scripts
|
message(STATUS "Force enabling ccache usage under macOS")
|
||||||
configure_file("${COCKATRICE_CMAKE_PATH}/launch-c.in" launch-c)
|
|
||||||
configure_file("${COCKATRICE_CMAKE_PATH}/launch-cxx.in" launch-cxx)
|
|
||||||
execute_process(COMMAND chmod a+rx "${CMAKE_BINARY_DIR}/launch-c" "${CMAKE_BINARY_DIR}/launch-cxx")
|
|
||||||
|
|
||||||
# Set Xcode project attributes to route compilation through our scripts
|
# Set up wrapper scripts
|
||||||
set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_BINARY_DIR}/launch-c")
|
configure_file("${COCKATRICE_CMAKE_PATH}/launch-c.in" launch-c)
|
||||||
set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_BINARY_DIR}/launch-cxx")
|
configure_file("${COCKATRICE_CMAKE_PATH}/launch-cxx.in" launch-cxx)
|
||||||
set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c")
|
execute_process(COMMAND chmod a+rx "${CMAKE_BINARY_DIR}/launch-c" "${CMAKE_BINARY_DIR}/launch-cxx")
|
||||||
set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx")
|
|
||||||
|
# Set Xcode project attributes to route compilation through our scripts
|
||||||
|
set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_BINARY_DIR}/launch-c")
|
||||||
|
set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_BINARY_DIR}/launch-cxx")
|
||||||
|
set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c")
|
||||||
|
set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx")
|
||||||
|
endif()
|
||||||
endif()
|
endif()
|
||||||
else()
|
else() # Linux / BSD
|
||||||
# Linux / BSD
|
|
||||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||||
#fix package build
|
# Fix package build
|
||||||
if(PREFIX)
|
if(PREFIX)
|
||||||
set(CMAKE_INSTALL_PREFIX ${PREFIX})
|
set(CMAKE_INSTALL_PREFIX ${PREFIX})
|
||||||
else()
|
else()
|
||||||
|
|
@ -166,47 +163,68 @@ if(UNIX)
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
elseif(WIN32)
|
elseif(WIN32) # Windows (including 64-bit)
|
||||||
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/rundir/${CMAKE_BUILD_TYPE})
|
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/rundir/${CMAKE_BUILD_TYPE})
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Define proper compilation flags
|
# Define compiler flags
|
||||||
if(MSVC)
|
if(MSVC) # Microsoft Visual C++ compiler
|
||||||
# Disable Warning C4251, C++20 compatibility, Multi-threaded Builds, Warn Detection, Unwind Semantics, Debug Symbols
|
# /EHsc Enable standard C++ exception handling
|
||||||
set(CMAKE_CXX_FLAGS "/wd4251 /Zc:__cplusplus /std:c++20 /permissive- /W4 /MP /EHsc /Zi")
|
# /MP Enable parallel compilation
|
||||||
# Visual Studio: Maximum Optimization, Multi-threaded DLL
|
# /permissive- Enable more standards-conforming behavior
|
||||||
set(CMAKE_CXX_FLAGS_RELEASE "/Ox /MD")
|
# /utf-8 Set source file encoding and execution character set to UTF-8
|
||||||
# Visual Studio: No Optimization, Multi-threaded Debug DLL
|
# /W4 Enable warning level 4
|
||||||
|
# /Zc:__cplusplus Enable correct __cplusplus reporting
|
||||||
|
# /Z7 Generate debugging information in each object file (ccache-cacheable)
|
||||||
|
# instead of a compiler-shared PDB, which ccache rejects
|
||||||
|
set(CMAKE_CXX_FLAGS "/EHsc /MP /permissive- /utf-8 /W4 /Zc:__cplusplus /Z7")
|
||||||
|
# /O2 Balanced optimization
|
||||||
|
# /MD Link against the multi-threaded DLL runtime library (Release CRT)
|
||||||
|
set(CMAKE_CXX_FLAGS_RELEASE "/O2 /MD")
|
||||||
|
# /Od Disable optimization
|
||||||
|
# /MDd Link against the multi-threaded Debug DLL runtime library (Debug CRT)
|
||||||
set(CMAKE_CXX_FLAGS_DEBUG "/Od /MDd")
|
set(CMAKE_CXX_FLAGS_DEBUG "/Od /MDd")
|
||||||
|
|
||||||
# Generate PDB, even when in release (So developers can better analyze crash logs)
|
# Generate PDBs, even in release builds, so developers can better analyze crash logs
|
||||||
|
# /DEBUG Enable debug symbols and PDB generation
|
||||||
|
# /OPT:REF Remove unreferenced functions/data
|
||||||
|
# /OPT:ICF Fold identical COMDAT functions/data
|
||||||
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF")
|
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF")
|
||||||
|
|
||||||
add_compile_definitions(_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING)
|
add_compile_definitions(_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING)
|
||||||
elseif(CMAKE_COMPILER_IS_GNUCXX)
|
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") # GCC compiler
|
||||||
# linux/gcc, bsd/gcc, windows/mingw
|
|
||||||
include(CheckCXXCompilerFlag)
|
include(CheckCXXCompilerFlag)
|
||||||
|
|
||||||
set(CMAKE_CXX_FLAGS_RELEASE "-s -O2")
|
# -O2 Balanced optimization
|
||||||
|
# -s Remove symbols from the executable
|
||||||
|
set(CMAKE_CXX_FLAGS_RELEASE "-O2 -s")
|
||||||
|
|
||||||
|
# -ggdb Produce GDB debugging symbols
|
||||||
|
# -O0 Disable optimization
|
||||||
|
# -Wall Enable a broad set of useful warnings
|
||||||
|
# -Wextra Enable a set of extra warnings
|
||||||
|
# -Werror Treat warnings as compilation errors
|
||||||
if(WARNING_AS_ERROR)
|
if(WARNING_AS_ERROR)
|
||||||
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra -Werror")
|
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra -Werror")
|
||||||
else()
|
else()
|
||||||
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra")
|
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
if(APPLE)
|
# -Wcast-align Catch unsafe pointer casts
|
||||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++20")
|
# -Wmissing-declarations Catch defined functions without a prior declaration
|
||||||
endif()
|
# -Wno-error=extra Downgrade some -Wextra warnings from errors to warnings
|
||||||
|
# -Wno-error=delete-non-virtual-dtor Downgrade delete-non-virtual-dtor from error to warning
|
||||||
|
# -Wno-error=sign-compare Downgrade signed/unsigned comparison from error to warning
|
||||||
|
# -Wno-error=missing-declarations Downgrade -Wmissing-declarations from error to warning
|
||||||
|
# -Wno-error=sfinae-incomplete GCC 16+: Qt MOC + protobuf forward decls trigger this
|
||||||
set(ADDITIONAL_DEBUG_FLAGS
|
set(ADDITIONAL_DEBUG_FLAGS
|
||||||
-Wcast-align
|
-Wcast-align
|
||||||
-Wmissing-declarations
|
-Wmissing-declarations
|
||||||
-Wno-long-long
|
|
||||||
-Wno-error=extra
|
-Wno-error=extra
|
||||||
-Wno-error=delete-non-virtual-dtor
|
-Wno-error=delete-non-virtual-dtor
|
||||||
-Wno-error=sign-compare
|
-Wno-error=sign-compare
|
||||||
-Wno-error=missing-declarations
|
-Wno-error=missing-declarations
|
||||||
-Wno-error=sfinae-incomplete # GCC 16+: Qt MOC + protobuf forward decls trigger this
|
-Wno-error=sfinae-incomplete
|
||||||
)
|
)
|
||||||
|
|
||||||
foreach(FLAG ${ADDITIONAL_DEBUG_FLAGS})
|
foreach(FLAG ${ADDITIONAL_DEBUG_FLAGS})
|
||||||
|
|
@ -218,9 +236,16 @@ elseif(CMAKE_COMPILER_IS_GNUCXX)
|
||||||
|
|
||||||
# Reduce compiler I/O by using pipes between stages instead of temp files
|
# Reduce compiler I/O by using pipes between stages instead of temp files
|
||||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
|
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
|
||||||
else()
|
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang and AppleClang compilers
|
||||||
# other: osx/llvm, bsd/llvm
|
# -O2 Balanced optimization
|
||||||
set(CMAKE_CXX_FLAGS_RELEASE "-O2")
|
set(CMAKE_CXX_FLAGS_RELEASE "-O2")
|
||||||
|
|
||||||
|
# -g Include debug information
|
||||||
|
# -O0 Disable optimization
|
||||||
|
# -Wall Enable a broad set of useful warnings
|
||||||
|
# -Wextra Enable a set of extra warnings
|
||||||
|
# -Werror Treat warnings as compilation errors
|
||||||
|
# -Wno-unused-parameter Suppress warnings about unused function parameters (common in Qt callbacks)
|
||||||
if(WARNING_AS_ERROR)
|
if(WARNING_AS_ERROR)
|
||||||
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra -Werror -Wno-unused-parameter")
|
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra -Werror -Wno-unused-parameter")
|
||||||
else()
|
else()
|
||||||
|
|
@ -229,6 +254,8 @@ else()
|
||||||
|
|
||||||
# Reduce compiler I/O by using pipes between stages instead of temp files
|
# Reduce compiler I/O by using pipes between stages instead of temp files
|
||||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
|
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
|
||||||
|
else() # Unknown compiler
|
||||||
|
message(WARNING "Unknown C++ compiler: ${CMAKE_CXX_COMPILER_ID}")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Always included, because they define cockatrice_disable_sanitizers_for() and
|
# Always included, because they define cockatrice_disable_sanitizers_for() and
|
||||||
|
|
@ -238,9 +265,37 @@ include(Sanitizers)
|
||||||
include(Coverage)
|
include(Coverage)
|
||||||
include(CrashReporting)
|
include(CrashReporting)
|
||||||
|
|
||||||
# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning
|
# Debug information is what makes a cached object large, and a build configuration needs more
|
||||||
|
# room than a per-leg cache bound can spare when it carries full DWARF: ccache then fills to
|
||||||
|
# its limit and evicts the entries the next build is about to ask for, so it serves a few
|
||||||
|
# percent of calls while holding most of a build. CI sets a lower level so one whole
|
||||||
|
# configuration fits; level 1 keeps file and line, which is what a failing test or a sanitizer
|
||||||
|
# backtrace reports. Empty (the default) leaves local, distro and release builds exactly as
|
||||||
|
# they were. This comes after CrashReporting, so the -g it adds for Sentry symbols cannot
|
||||||
|
# raise the level again.
|
||||||
|
set(COCKATRICE_DEBUG_INFO_LEVEL
|
||||||
|
""
|
||||||
|
CACHE STRING "DWARF level for Debug and Release objects (empty keeps the built-in flags)"
|
||||||
|
)
|
||||||
|
if(NOT COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "")
|
||||||
|
if(MSVC)
|
||||||
|
# MSVC has no -g level, so /Z7 is either there or it is not. Level 0 is the only value
|
||||||
|
# that means something here: without /Z7 the objects carry no debug information, which is
|
||||||
|
# what a cache that has to fit a bound holds. Anything else is the full debug info.
|
||||||
|
if(COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "0")
|
||||||
|
string(REPLACE " /Z7" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}")
|
||||||
|
else()
|
||||||
|
message(WARNING "COCKATRICE_DEBUG_INFO_LEVEL is ignored on MSVC except 0")
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
string(APPEND CMAKE_CXX_FLAGS_DEBUG " -g${COCKATRICE_DEBUG_INFO_LEVEL}")
|
||||||
|
string(APPEND CMAKE_CXX_FLAGS_RELEASE " -g${COCKATRICE_DEBUG_INFO_LEVEL}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# GNU systems need to define the Mersenne exponent for SFMT, so the RNG compiles without warnings
|
||||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||||
add_definitions("-DSFMT_MEXP=19937")
|
add_compile_definitions(SFMT_MEXP=19937)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
find_package(Threads REQUIRED)
|
find_package(Threads REQUIRED)
|
||||||
|
|
@ -293,15 +348,15 @@ if(${Protobuf_VERSION} VERSION_LESS "3.21.0.0" AND NOT EXISTS "${Protobuf_PROTOC
|
||||||
message(FATAL_ERROR "No protoc command found!")
|
message(FATAL_ERROR "No protoc command found!")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
#Find OpenSSL
|
# Find OpenSSL
|
||||||
if(WIN32)
|
if(WIN32) # Windows (including 64-bit)
|
||||||
find_package(OpenSSL REQUIRED)
|
find_package(OpenSSL REQUIRED)
|
||||||
if(OPENSSL_FOUND)
|
if(OPENSSL_FOUND)
|
||||||
include_directories(${OPENSSL_INCLUDE_DIRS})
|
include_directories(${OPENSSL_INCLUDE_DIRS})
|
||||||
endif()
|
endif()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
#Find VCredist
|
# Find Visual C++ Redistributable
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
find_package(VCredistRuntime)
|
find_package(VCredistRuntime)
|
||||||
endif()
|
endif()
|
||||||
|
|
@ -514,3 +569,10 @@ if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3)
|
||||||
# Qt6.3+ requires project finalization to support translations
|
# Qt6.3+ requires project finalization to support translations
|
||||||
qt_finalize_project()
|
qt_finalize_project()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# Print compiler identification at configuration time
|
||||||
|
message(STATUS "C++ compiler: ${CMAKE_CXX_COMPILER_ID}")
|
||||||
|
message(STATUS "C++ compiler version: ${CMAKE_CXX_COMPILER_VERSION}")
|
||||||
|
message(STATUS "C++ compiler path: ${CMAKE_CXX_COMPILER}")
|
||||||
|
message(STATUS "C++ standard: ${CMAKE_CXX_STANDARD}")
|
||||||
|
message(STATUS "C++ extensions: ${CMAKE_CXX_EXTENSIONS}")
|
||||||
|
|
|
||||||
24
Doxyfile
|
|
@ -74,7 +74,7 @@ PROJECT_ICON = cockatrice/resources/cockatrice.png
|
||||||
# entered, it will be relative to the location where Doxygen was started. If
|
# entered, it will be relative to the location where Doxygen was started. If
|
||||||
# left blank the current directory will be used.
|
# left blank the current directory will be used.
|
||||||
|
|
||||||
OUTPUT_DIRECTORY = docs
|
OUTPUT_DIRECTORY = docs-output
|
||||||
|
|
||||||
# If the CREATE_SUBDIRS tag is set to YES then Doxygen will create up to 4096
|
# If the CREATE_SUBDIRS tag is set to YES then Doxygen will create up to 4096
|
||||||
# sub-directories (in 2 levels) under the output directory of each output format
|
# sub-directories (in 2 levels) under the output directory of each output format
|
||||||
|
|
@ -874,7 +874,7 @@ FILE_VERSION_FILTER =
|
||||||
# DoxygenLayout.xml, Doxygen will parse it automatically even if the LAYOUT_FILE
|
# DoxygenLayout.xml, Doxygen will parse it automatically even if the LAYOUT_FILE
|
||||||
# tag is left empty.
|
# tag is left empty.
|
||||||
|
|
||||||
LAYOUT_FILE = doc/doxygen/DoxygenLayout.xml
|
LAYOUT_FILE = docs/doxygen/DoxygenLayout.xml
|
||||||
|
|
||||||
# The CITE_BIB_FILES tag can be used to specify one or more bib files containing
|
# The CITE_BIB_FILES tag can be used to specify one or more bib files containing
|
||||||
# the reference definitions. This must be a list of .bib files. The .bib
|
# the reference definitions. This must be a list of .bib files. The .bib
|
||||||
|
|
@ -1091,8 +1091,8 @@ RECURSIVE = YES
|
||||||
EXCLUDE = build/ \
|
EXCLUDE = build/ \
|
||||||
cmake/ \
|
cmake/ \
|
||||||
cmake-build-debug/ \
|
cmake-build-debug/ \
|
||||||
doc/doxygen/theme/docs/ \
|
docs/doxygen/theme/docs/ \
|
||||||
doc/doxygen/theme/include/ \
|
docs/doxygen/theme/include/ \
|
||||||
vcpkg/
|
vcpkg/
|
||||||
|
|
||||||
# The EXCLUDE_SYMLINKS tag can be used to select whether or not files or
|
# The EXCLUDE_SYMLINKS tag can be used to select whether or not files or
|
||||||
|
|
@ -1144,7 +1144,7 @@ EXAMPLE_RECURSIVE = NO
|
||||||
# that contain images that are to be included in the documentation (see the
|
# that contain images that are to be included in the documentation (see the
|
||||||
# \image command).
|
# \image command).
|
||||||
|
|
||||||
IMAGE_PATH = doc/doxygen/images
|
IMAGE_PATH = docs/doxygen/images
|
||||||
|
|
||||||
# The INPUT_FILTER tag can be used to specify a program that Doxygen should
|
# The INPUT_FILTER tag can be used to specify a program that Doxygen should
|
||||||
# invoke to filter for each input file. Doxygen will invoke the filter program
|
# invoke to filter for each input file. Doxygen will invoke the filter program
|
||||||
|
|
@ -1183,7 +1183,7 @@ INPUT_FILTER =
|
||||||
# need to set EXTENSION_MAPPING for the extension otherwise the files are not
|
# need to set EXTENSION_MAPPING for the extension otherwise the files are not
|
||||||
# properly processed by Doxygen.
|
# properly processed by Doxygen.
|
||||||
|
|
||||||
FILTER_PATTERNS = "*.proto=python doc/doxygen/filters/proto2cpp.py"
|
FILTER_PATTERNS = "*.proto=python docs/doxygen/filters/proto2cpp.py"
|
||||||
|
|
||||||
# If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using
|
# If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using
|
||||||
# INPUT_FILTER) will also be used to filter the input files that are used for
|
# INPUT_FILTER) will also be used to filter the input files that are used for
|
||||||
|
|
@ -1414,7 +1414,7 @@ HTML_FILE_EXTENSION = .html
|
||||||
# of the possible markers and block names see the documentation.
|
# of the possible markers and block names see the documentation.
|
||||||
# This tag requires that the tag GENERATE_HTML is set to YES.
|
# This tag requires that the tag GENERATE_HTML is set to YES.
|
||||||
|
|
||||||
HTML_HEADER = doc/doxygen/html/header.html
|
HTML_HEADER = docs/doxygen/html/header.html
|
||||||
|
|
||||||
# The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each
|
# The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each
|
||||||
# generated HTML page. If the tag is left blank Doxygen will generate a standard
|
# generated HTML page. If the tag is left blank Doxygen will generate a standard
|
||||||
|
|
@ -1424,7 +1424,7 @@ HTML_HEADER = doc/doxygen/html/header.html
|
||||||
# that Doxygen normally uses.
|
# that Doxygen normally uses.
|
||||||
# This tag requires that the tag GENERATE_HTML is set to YES.
|
# This tag requires that the tag GENERATE_HTML is set to YES.
|
||||||
|
|
||||||
HTML_FOOTER = doc/doxygen/html/footer.html
|
HTML_FOOTER = docs/doxygen/html/footer.html
|
||||||
|
|
||||||
# The HTML_STYLESHEET tag can be used to specify a user-defined cascading style
|
# The HTML_STYLESHEET tag can be used to specify a user-defined cascading style
|
||||||
# sheet that is used by each HTML page. It can be used to fine-tune the look of
|
# sheet that is used by each HTML page. It can be used to fine-tune the look of
|
||||||
|
|
@ -1454,9 +1454,9 @@ HTML_STYLESHEET =
|
||||||
# documentation.
|
# documentation.
|
||||||
# This tag requires that the tag GENERATE_HTML is set to YES.
|
# This tag requires that the tag GENERATE_HTML is set to YES.
|
||||||
|
|
||||||
HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \
|
HTML_EXTRA_STYLESHEET = docs/doxygen/theme/doxygen-awesome.css \
|
||||||
doc/doxygen/css/hide_nav_sync.css \
|
docs/doxygen/css/hide_nav_sync.css \
|
||||||
doc/doxygen/css/cockatrice_docs_style.css
|
docs/doxygen/css/cockatrice_docs_style.css
|
||||||
|
|
||||||
# The HTML_EXTRA_FILES tag can be used to specify one or more extra images or
|
# The HTML_EXTRA_FILES tag can be used to specify one or more extra images or
|
||||||
# other source files which should be copied to the HTML output directory. Note
|
# other source files which should be copied to the HTML output directory. Note
|
||||||
|
|
@ -1466,7 +1466,7 @@ HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \
|
||||||
# files will be copied as-is; there are no commands or markers available.
|
# files will be copied as-is; there are no commands or markers available.
|
||||||
# This tag requires that the tag GENERATE_HTML is set to YES.
|
# This tag requires that the tag GENERATE_HTML is set to YES.
|
||||||
|
|
||||||
HTML_EXTRA_FILES = doc/doxygen/js/graph_toggle.js
|
HTML_EXTRA_FILES = docs/doxygen/js/graph_toggle.js
|
||||||
|
|
||||||
# The HTML_COLORSTYLE tag can be used to specify if the generated HTML output
|
# The HTML_COLORSTYLE tag can be used to specify if the generated HTML output
|
||||||
# should be rendered with a dark or light theme.
|
# should be rendered with a dark or light theme.
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ Join our [Discord community](https://discord.gg/3Z9yzmA) to connect with other p
|
||||||
|
|
||||||
### Code
|
### Code
|
||||||
|
|
||||||
To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.<br>
|
To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/docs/CONTRIBUTING.md) first.<br>
|
||||||
We maintain two tags for contributors to easier find issues to potentially work on:
|
We maintain two tags for contributors to easier find issues to potentially work on:
|
||||||
- [](https://github.com/Cockatrice/Cockatrice/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22Good%20first%20issue%22%20)<br>
|
- [](https://github.com/Cockatrice/Cockatrice/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22Good%20first%20issue%22%20)<br>
|
||||||
Issues tagged in this way provide a simple way to get started. They don't require much experience to be worked on.
|
Issues tagged in this way provide a simple way to get started. They don't require much experience to be worked on.
|
||||||
|
|
@ -103,7 +103,7 @@ There are various places where useful information for different needs are mainta
|
||||||
- [Official Wiki](https://github.com/Cockatrice/Cockatrice/wiki) `Community supported`
|
- [Official Wiki](https://github.com/Cockatrice/Cockatrice/wiki) `Community supported`
|
||||||
- [Official Webpage](https://cockatrice.github.io/)
|
- [Official Webpage](https://cockatrice.github.io/)
|
||||||
- [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file`
|
- [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file`
|
||||||
- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md)
|
- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/docs/CONTRIBUTING.md)
|
||||||
|
|
||||||
Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation.
|
Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation.
|
||||||
We encourage everybody to improve the documentation by suggesting edits based on this guide.
|
We encourage everybody to improve the documentation by suggesting edits based on this guide.
|
||||||
|
|
@ -163,7 +163,7 @@ The following flags (with their non-default values) can be passed to `cmake`:
|
||||||
| `-DTEST=1` | Enable regression tests<br> **Note:** `make test` to run tests, *googletest* will be downloaded if not available |
|
| `-DTEST=1` | Enable regression tests<br> **Note:** `make test` to run tests, *googletest* will be downloaded if not available |
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs`:
|
> Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs-output`:
|
||||||
> ```bash
|
> ```bash
|
||||||
> doxygen Doxyfile
|
> doxygen Doxyfile
|
||||||
> ```
|
> ```
|
||||||
|
|
|
||||||
|
|
@ -291,9 +291,14 @@ set(cockatrice_SOURCES
|
||||||
src/interface/widgets/settings_page/storage_settings_page.cpp
|
src/interface/widgets/settings_page/storage_settings_page.cpp
|
||||||
src/interface/widgets/settings_page/user_interface_settings_page.cpp
|
src/interface/widgets/settings_page/user_interface_settings_page.cpp
|
||||||
src/interface/widgets/utility/custom_line_edit.cpp
|
src/interface/widgets/utility/custom_line_edit.cpp
|
||||||
|
src/interface/widgets/utility/epoch_format.h
|
||||||
src/interface/widgets/utility/get_text_with_max.cpp
|
src/interface/widgets/utility/get_text_with_max.cpp
|
||||||
|
src/interface/widgets/utility/pill_painter.cpp
|
||||||
|
src/interface/widgets/utility/pill_painter.h
|
||||||
src/interface/widgets/utility/report_utils.cpp
|
src/interface/widgets/utility/report_utils.cpp
|
||||||
src/interface/widgets/utility/report_utils.h
|
src/interface/widgets/utility/report_utils.h
|
||||||
|
src/interface/widgets/utility/table_activation.cpp
|
||||||
|
src/interface/widgets/utility/table_activation.h
|
||||||
src/interface/widgets/utility/sequence_edit.cpp
|
src/interface/widgets/utility/sequence_edit.cpp
|
||||||
src/interface/widgets/utility/visibility_change_listener.cpp
|
src/interface/widgets/utility/visibility_change_listener.cpp
|
||||||
src/interface/widgets/utility/visibility_change_listener.h
|
src/interface/widgets/utility/visibility_change_listener.h
|
||||||
|
|
@ -381,9 +386,12 @@ set(cockatrice_SOURCES
|
||||||
src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp
|
src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp
|
||||||
src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp
|
src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp
|
||||||
src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp
|
src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp
|
||||||
|
src/interface/widgets/tabs/alt_table_delegate.cpp
|
||||||
|
src/interface/widgets/tabs/report_status_delegate.cpp
|
||||||
src/interface/widgets/tabs/tab.cpp
|
src/interface/widgets/tabs/tab.cpp
|
||||||
src/interface/widgets/tabs/tab_account.cpp
|
src/interface/widgets/tabs/tab_account.cpp
|
||||||
src/interface/widgets/tabs/tab_admin.cpp
|
src/interface/widgets/tabs/tab_admin.cpp
|
||||||
|
src/interface/widgets/tabs/tab_alts.cpp
|
||||||
src/interface/widgets/tabs/tab_card_art_rules.cpp
|
src/interface/widgets/tabs/tab_card_art_rules.cpp
|
||||||
src/interface/widgets/tabs/tab_deck_editor.cpp
|
src/interface/widgets/tabs/tab_deck_editor.cpp
|
||||||
src/interface/widgets/tabs/tab_deck_storage.cpp
|
src/interface/widgets/tabs/tab_deck_storage.cpp
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
#include "update_downloader.h"
|
#include "update_downloader.h"
|
||||||
|
|
||||||
#include <QByteArray>
|
|
||||||
#include <QChar>
|
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFileInfo>
|
||||||
#include <QIODevice>
|
#include <QIODevice>
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QTemporaryDir>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
#include <libcockatrice/network/network_manager.h>
|
#include <libcockatrice/network/network_manager.h>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr)
|
UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr)
|
||||||
{
|
{
|
||||||
|
|
@ -23,48 +24,116 @@ void UpdateDownloader::beginDownload(QUrl downloadUrl)
|
||||||
originalUrl = downloadUrl;
|
originalUrl = downloadUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (auto *old = std::exchange(response, nullptr)) {
|
||||||
|
old->abort();
|
||||||
|
old->deleteLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
errorReported = false;
|
||||||
|
cancelRequested = false;
|
||||||
|
|
||||||
|
// The transfer timeout comes from the manager (NetworkManagerFactory).
|
||||||
response = netMan->get(QNetworkRequest(downloadUrl));
|
response = netMan->get(QNetworkRequest(downloadUrl));
|
||||||
connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished);
|
connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished);
|
||||||
|
connect(response, &QNetworkReply::errorOccurred, this, &UpdateDownloader::downloadError);
|
||||||
connect(response, &QNetworkReply::downloadProgress, this, &UpdateDownloader::downloadProgress);
|
connect(response, &QNetworkReply::downloadProgress, this, &UpdateDownloader::downloadProgress);
|
||||||
connect(this, &UpdateDownloader::stopDownload, response, &QNetworkReply::abort);
|
connect(this, &UpdateDownloader::stopDownload, this, &UpdateDownloader::cancelDownload);
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdateDownloader::downloadError(QNetworkReply::NetworkError)
|
void UpdateDownloader::downloadError(QNetworkReply::NetworkError networkError)
|
||||||
{
|
{
|
||||||
if (response == nullptr) {
|
// A reply that was superseded by beginDownload() can still deliver a
|
||||||
|
// queued error; it must not be reported against the current reply.
|
||||||
|
if (response == nullptr || sender() != response || errorReported) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emit error(response->errorString().toUtf8());
|
// A transfer timeout aborts the reply, so it arrives as OperationCanceledError.
|
||||||
|
// Only an abort the user asked for is suppressed; every other cancel is a
|
||||||
|
// stalled transfer and has to be reported.
|
||||||
|
if (networkError == QNetworkReply::OperationCanceledError) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorReported = true;
|
||||||
|
emit error(tr("The download timed out. Check your connection and try again."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorReported = true;
|
||||||
|
emit error(response->errorString());
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateDownloader::cancelDownload()
|
||||||
|
{
|
||||||
|
cancelRequested = true;
|
||||||
|
if (response != nullptr) {
|
||||||
|
response->abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdateDownloader::fileFinished()
|
void UpdateDownloader::fileFinished()
|
||||||
{
|
{
|
||||||
// If we finished but there's a redirect, follow it
|
// An aborted, superseded reply also emits finished(). Only the reply
|
||||||
QVariant redirect = response->attribute(QNetworkRequest::RedirectionTargetAttribute);
|
// currently stored in response may be consumed.
|
||||||
if (!redirect.isNull()) {
|
if (response == nullptr || sender() != response) {
|
||||||
beginDownload(redirect.toUrl());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle any errors we had
|
// errorOccurred normally reports this first; keep the check for errors that
|
||||||
if (response->error()) {
|
// only surface when the reply finishes.
|
||||||
emit error(response->errorString());
|
if (response->error() != QNetworkReply::NoError) {
|
||||||
|
downloadError(response->error());
|
||||||
|
response->deleteLater();
|
||||||
|
response = nullptr;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Work out the file name of the download
|
// Give the update its own private directory. The name is unpredictable, so a
|
||||||
QString fileName = QDir::temp().path() + QDir::separator() + originalUrl.toString().section('/', -1);
|
// local user cannot pre-create or symlink the path, and the original file
|
||||||
|
// name (including the installer suffix) is preserved inside it.
|
||||||
|
QTemporaryDir downloadDir(QDir::temp().filePath(QStringLiteral("cockatrice-update-XXXXXX")));
|
||||||
|
if (!downloadDir.isValid()) {
|
||||||
|
emit error(tr("Could not create a temporary directory for the download."));
|
||||||
|
response->deleteLater();
|
||||||
|
response = nullptr;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadDir.setAutoRemove(false);
|
||||||
|
|
||||||
// Save the build in a temporary directory
|
const QString remoteName = QFileInfo(originalUrl.path()).fileName();
|
||||||
QFile file(fileName);
|
const QString fileName = downloadDir.filePath(remoteName.isEmpty() ? QStringLiteral("update") : remoteName);
|
||||||
|
|
||||||
|
QSaveFile file(fileName);
|
||||||
if (!file.open(QIODevice::WriteOnly)) {
|
if (!file.open(QIODevice::WriteOnly)) {
|
||||||
emit error(tr("Could not open the file for reading."));
|
downloadDir.remove();
|
||||||
|
emit error(tr("Could not open the file for writing: %1").arg(file.errorString()));
|
||||||
|
response->deleteLater();
|
||||||
|
response = nullptr;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
file.write(response->readAll());
|
const QByteArray data = response->readAll();
|
||||||
file.close();
|
if (file.write(data) != data.size()) {
|
||||||
|
file.cancelWriting();
|
||||||
|
downloadDir.remove();
|
||||||
|
emit error(tr("Could not write the downloaded file to disk."));
|
||||||
|
response->deleteLater();
|
||||||
|
response = nullptr;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.commit()) {
|
||||||
|
downloadDir.remove();
|
||||||
|
emit error(tr("Could not save the downloaded file: %1").arg(file.errorString()));
|
||||||
|
response->deleteLater();
|
||||||
|
response = nullptr;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response->deleteLater();
|
||||||
|
response = nullptr;
|
||||||
|
|
||||||
// Emit the success signal with a URL to the download file
|
// Emit the success signal with a URL to the download file
|
||||||
emit downloadSuccessful(QUrl::fromLocalFile(fileName));
|
emit downloadSuccessful(QUrl::fromLocalFile(fileName));
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,13 @@ private:
|
||||||
QUrl originalUrl;
|
QUrl originalUrl;
|
||||||
QNetworkAccessManager *netMan;
|
QNetworkAccessManager *netMan;
|
||||||
QNetworkReply *response;
|
QNetworkReply *response;
|
||||||
|
bool errorReported = false;
|
||||||
|
bool cancelRequested = false;
|
||||||
private slots:
|
private slots:
|
||||||
void fileFinished();
|
void fileFinished();
|
||||||
void downloadProgress(qint64 bytesRead, qint64 totalBytes);
|
void downloadProgress(qint64 bytesRead, qint64 totalBytes);
|
||||||
void downloadError(QNetworkReply::NetworkError);
|
void downloadError(QNetworkReply::NetworkError);
|
||||||
|
void cancelDownload();
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // COCKATRICE_UPDATEDOWNLOADER_H
|
#endif // COCKATRICE_UPDATEDOWNLOADER_H
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ void ArrowItem::onTargetDestroyed()
|
||||||
// Drag and attach arrows are never inserted into the arrow registry and
|
// Drag and attach arrows are never inserted into the arrow registry and
|
||||||
// have no server-side counterpart, so no deletion event can clean them
|
// have no server-side counterpart, so no deletion event can clean them
|
||||||
// up. Delete them locally when either endpoint is destroyed.
|
// up. Delete them locally when either endpoint is destroyed.
|
||||||
delArrow();
|
cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,6 +103,12 @@ void ArrowItem::delArrow()
|
||||||
deleteLater();
|
deleteLater();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ArrowItem::cancel()
|
||||||
|
{
|
||||||
|
ungrabMouse();
|
||||||
|
delArrow();
|
||||||
|
}
|
||||||
|
|
||||||
void ArrowItem::updatePath()
|
void ArrowItem::updatePath()
|
||||||
{
|
{
|
||||||
if (!targetItem) {
|
if (!targetItem) {
|
||||||
|
|
@ -350,6 +356,17 @@ void ArrowDragItem::addChildArrow(ArrowDragItem *child)
|
||||||
childArrows.append(child);
|
childArrows.append(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ArrowDragItem::cancel()
|
||||||
|
{
|
||||||
|
for (const auto &child : childArrows) {
|
||||||
|
if (child) {
|
||||||
|
child->cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
childArrows.clear();
|
||||||
|
ArrowItem::cancel();
|
||||||
|
}
|
||||||
|
|
||||||
void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||||
{
|
{
|
||||||
if (targetLocked || !startItem) {
|
if (targetLocked || !startItem) {
|
||||||
|
|
@ -393,8 +410,10 @@ void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||||
targetItem ? updatePath() : updatePath(endPos);
|
targetItem ? updatePath() : updatePath(endPos);
|
||||||
update();
|
update();
|
||||||
|
|
||||||
for (auto *child : childArrows) {
|
for (const auto &child : childArrows) {
|
||||||
child->mouseMoveEvent(event);
|
if (child) {
|
||||||
|
child->mouseMoveEvent(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -403,10 +422,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||||
if (!startItem) {
|
if (!startItem) {
|
||||||
// The source card was destroyed while the arrow was being drawn.
|
// The source card was destroyed while the arrow was being drawn.
|
||||||
// Clean up the arrow and its children instead of leaking them.
|
// Clean up the arrow and its children instead of leaking them.
|
||||||
delArrow();
|
cancel();
|
||||||
for (auto *child : childArrows) {
|
|
||||||
child->mouseReleaseEvent(event);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -415,7 +431,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||||
// For now, we can safely assume that the start item is always a card.
|
// For now, we can safely assume that the start item is always a card.
|
||||||
// The target item can be a player as well.
|
// The target item can be a player as well.
|
||||||
if (!startCard) {
|
if (!startCard) {
|
||||||
delArrow();
|
cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,7 +451,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||||
} else if (auto *targetPlayer = qgraphicsitem_cast<PlayerTarget *>(targetItem)) {
|
} else if (auto *targetPlayer = qgraphicsitem_cast<PlayerTarget *>(targetItem)) {
|
||||||
arrow.targetPlayerId = targetPlayer->getOwner()->getPlayerInfo()->getId();
|
arrow.targetPlayerId = targetPlayer->getOwner()->getPlayerInfo()->getId();
|
||||||
} else {
|
} else {
|
||||||
delArrow();
|
cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -448,9 +464,12 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||||
}
|
}
|
||||||
|
|
||||||
delArrow();
|
delArrow();
|
||||||
for (auto *child : childArrows) {
|
for (const auto &child : childArrows) {
|
||||||
child->mouseReleaseEvent(event);
|
if (child) {
|
||||||
|
child->mouseReleaseEvent(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
childArrows.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArrowAttachItem
|
// ArrowAttachItem
|
||||||
|
|
@ -471,6 +490,17 @@ void ArrowAttachItem::addChildArrow(ArrowAttachItem *child)
|
||||||
childArrows.append(child);
|
childArrows.append(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ArrowAttachItem::cancel()
|
||||||
|
{
|
||||||
|
for (const auto &child : childArrows) {
|
||||||
|
if (child) {
|
||||||
|
child->cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
childArrows.clear();
|
||||||
|
ArrowItem::cancel();
|
||||||
|
}
|
||||||
|
|
||||||
void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||||
{
|
{
|
||||||
if (targetLocked || !startItem) {
|
if (targetLocked || !startItem) {
|
||||||
|
|
@ -509,8 +539,10 @@ void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
||||||
targetItem ? updatePath() : updatePath(endPos);
|
targetItem ? updatePath() : updatePath(endPos);
|
||||||
update();
|
update();
|
||||||
|
|
||||||
for (auto *child : childArrows) {
|
for (const auto &child : childArrows) {
|
||||||
child->mouseMoveEvent(event);
|
if (child) {
|
||||||
|
child->mouseMoveEvent(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -519,17 +551,16 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||||
if (!startItem) {
|
if (!startItem) {
|
||||||
// The source card was destroyed while the arrow was being drawn.
|
// The source card was destroyed while the arrow was being drawn.
|
||||||
// Clean up the arrow and its children instead of leaking them.
|
// Clean up the arrow and its children instead of leaking them.
|
||||||
delArrow();
|
cancel();
|
||||||
for (auto *child : childArrows) {
|
|
||||||
child->mouseReleaseEvent(event);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attaching could move startItem under the current cursor position, causing all children to retarget to it right
|
// Attaching could move startItem under the current cursor position, causing all children to retarget to it right
|
||||||
// before they are processed. Prevent that.
|
// before they are processed. Prevent that.
|
||||||
for (auto *child : childArrows) {
|
for (const auto &child : childArrows) {
|
||||||
child->setTargetLocked(true);
|
if (child) {
|
||||||
|
child->setTargetLocked(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetItem && targetItem != startItem) {
|
if (targetItem && targetItem != startItem) {
|
||||||
|
|
@ -541,7 +572,10 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||||
}
|
}
|
||||||
|
|
||||||
delArrow();
|
delArrow();
|
||||||
for (auto *child : childArrows) {
|
for (const auto &child : childArrows) {
|
||||||
child->mouseReleaseEvent(event);
|
if (child) {
|
||||||
|
child->mouseReleaseEvent(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
childArrows.clear();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,8 @@ public:
|
||||||
|
|
||||||
void onTargetDestroyed();
|
void onTargetDestroyed();
|
||||||
void delArrow();
|
void delArrow();
|
||||||
|
/// Abandons an in-progress drag arrow, discarding any child arrows with it.
|
||||||
|
virtual void cancel();
|
||||||
void updatePath();
|
void updatePath();
|
||||||
void updatePath(const QPointF &endPoint);
|
void updatePath(const QPointF &endPoint);
|
||||||
void startDrawAnimation();
|
void startDrawAnimation();
|
||||||
|
|
@ -112,12 +114,13 @@ class ArrowDragItem : public ArrowItem
|
||||||
private:
|
private:
|
||||||
PlayerLogic *player;
|
PlayerLogic *player;
|
||||||
int deleteInPhase;
|
int deleteInPhase;
|
||||||
QList<ArrowDragItem *> childArrows;
|
QList<QPointer<ArrowDragItem>> childArrows;
|
||||||
QMetaObject::Connection positionConnection;
|
QMetaObject::Connection positionConnection;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
ArrowDragItem(PlayerLogic *_owner, ArrowTarget *_startItem, const QColor &_color, int _deleteInPhase);
|
ArrowDragItem(PlayerLogic *_owner, ArrowTarget *_startItem, const QColor &_color, int _deleteInPhase);
|
||||||
void addChildArrow(ArrowDragItem *child);
|
void addChildArrow(ArrowDragItem *child);
|
||||||
|
void cancel() override;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
|
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
|
||||||
|
|
@ -129,12 +132,13 @@ class ArrowAttachItem : public ArrowItem
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
private:
|
private:
|
||||||
PlayerLogic *player;
|
PlayerLogic *player;
|
||||||
QList<ArrowAttachItem *> childArrows;
|
QList<QPointer<ArrowAttachItem>> childArrows;
|
||||||
QMetaObject::Connection positionConnection;
|
QMetaObject::Connection positionConnection;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit ArrowAttachItem(ArrowTarget *_startItem);
|
explicit ArrowAttachItem(ArrowTarget *_startItem);
|
||||||
void addChildArrow(ArrowAttachItem *child);
|
void addChildArrow(ArrowAttachItem *child);
|
||||||
|
void cancel() override;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
|
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,13 @@ void CardItem::drawArrow(const QColor &arrowColor)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A held shortcut repeats the action before the previous arrow is placed.
|
||||||
|
// That arrow has lost the mouse grab to the new one, so it would never
|
||||||
|
// receive the release that deletes it; discard it (and its children).
|
||||||
|
if (pendingArrow) {
|
||||||
|
pendingArrow->cancel();
|
||||||
|
}
|
||||||
|
|
||||||
auto *game = owner->getGame();
|
auto *game = owner->getGame();
|
||||||
PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer());
|
PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer());
|
||||||
int phase = 0; // 0 means to not set the phase
|
int phase = 0; // 0 means to not set the phase
|
||||||
|
|
@ -303,6 +310,7 @@ void CardItem::drawArrow(const QColor &arrowColor)
|
||||||
}
|
}
|
||||||
ArrowDragItem *arrow = new ArrowDragItem(arrowOwner, this, arrowColor, phase);
|
ArrowDragItem *arrow = new ArrowDragItem(arrowOwner, this, arrowColor, phase);
|
||||||
scene()->addItem(arrow);
|
scene()->addItem(arrow);
|
||||||
|
pendingArrow = arrow;
|
||||||
arrow->grabMouse();
|
arrow->grabMouse();
|
||||||
|
|
||||||
for (const auto &item : scene()->selectedItems()) {
|
for (const auto &item : scene()->selectedItems()) {
|
||||||
|
|
@ -326,8 +334,14 @@ void CardItem::drawAttachArrow()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// See drawArrow(): a repeated trigger must not orphan the previous arrow.
|
||||||
|
if (pendingArrow) {
|
||||||
|
pendingArrow->cancel();
|
||||||
|
}
|
||||||
|
|
||||||
auto *arrow = new ArrowAttachItem(this);
|
auto *arrow = new ArrowAttachItem(this);
|
||||||
scene()->addItem(arrow);
|
scene()->addItem(arrow);
|
||||||
|
pendingArrow = arrow;
|
||||||
arrow->grabMouse();
|
arrow->grabMouse();
|
||||||
|
|
||||||
for (const auto &item : scene()->selectedItems()) {
|
for (const auto &item : scene()->selectedItems()) {
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,13 @@
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QPoint>
|
#include <QPoint>
|
||||||
#include <QPointF>
|
#include <QPointF>
|
||||||
|
#include <QPointer>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QVariantList>
|
#include <QVariantList>
|
||||||
#include <libcockatrice/game/board/card_state.h>
|
#include <libcockatrice/game/board/card_state.h>
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
|
class ArrowItem;
|
||||||
class CardDragItem;
|
class CardDragItem;
|
||||||
class ServerInfo_Card;
|
class ServerInfo_Card;
|
||||||
class PlayerLogic;
|
class PlayerLogic;
|
||||||
|
|
@ -34,6 +36,7 @@ class CardItem : public AbstractCardItem
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
private:
|
private:
|
||||||
CardDragItem *dragItem;
|
CardDragItem *dragItem;
|
||||||
|
QPointer<ArrowItem> pendingArrow;
|
||||||
|
|
||||||
void prepareDelete();
|
void prepareDelete();
|
||||||
void handleClickedToPlay(bool shiftHeld);
|
void handleClickedToPlay(bool shiftHeld);
|
||||||
|
|
|
||||||
|
|
@ -64,13 +64,13 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
|
||||||
QString zoneName = zone->getName();
|
QString zoneName = zone->getName();
|
||||||
|
|
||||||
if (zoneName == ZoneNames::TABLE) {
|
if (zoneName == ZoneNames::TABLE) {
|
||||||
fromStr = tr(" from play");
|
fromStr = tr("from play");
|
||||||
} else if (zoneName == ZoneNames::GRAVE) {
|
} else if (zoneName == ZoneNames::GRAVE) {
|
||||||
fromStr = tr(" from their graveyard");
|
fromStr = tr("from their graveyard");
|
||||||
} else if (zoneName == ZoneNames::EXILE) {
|
} else if (zoneName == ZoneNames::EXILE) {
|
||||||
fromStr = tr(" from exile");
|
fromStr = tr("from exile");
|
||||||
} else if (zoneName == ZoneNames::HAND) {
|
} else if (zoneName == ZoneNames::HAND) {
|
||||||
fromStr = tr(" from their hand");
|
fromStr = tr("from their hand");
|
||||||
} else if (zoneName == ZoneNames::DECK) {
|
} else if (zoneName == ZoneNames::DECK) {
|
||||||
if (position == 0) {
|
if (position == 0) {
|
||||||
if (cardName.isEmpty()) {
|
if (cardName.isEmpty()) {
|
||||||
|
|
@ -82,9 +82,9 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
|
||||||
cardNameContainsStartZone = true;
|
cardNameContainsStartZone = true;
|
||||||
} else {
|
} else {
|
||||||
if (ownerChange) {
|
if (ownerChange) {
|
||||||
fromStr = tr(" from the top of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
|
fromStr = tr("from the top of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
|
||||||
} else {
|
} else {
|
||||||
fromStr = tr(" from the top of their library");
|
fromStr = tr("from the top of their library");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (position == zone->getCards().size()) {
|
} else if (position == zone->getCards().size()) {
|
||||||
|
|
@ -97,29 +97,34 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
|
||||||
cardNameContainsStartZone = true;
|
cardNameContainsStartZone = true;
|
||||||
} else {
|
} else {
|
||||||
if (ownerChange) {
|
if (ownerChange) {
|
||||||
fromStr = tr(" from the bottom of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
|
fromStr = tr("from the bottom of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
|
||||||
} else {
|
} else {
|
||||||
fromStr = tr(" from the bottom of their library");
|
fromStr = tr("from the bottom of their library");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (ownerChange) {
|
if (ownerChange) {
|
||||||
fromStr = tr(" from %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
|
fromStr = tr("from %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
|
||||||
} else {
|
} else {
|
||||||
fromStr = tr(" from their library");
|
fromStr = tr("from their library");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (zoneName == ZoneNames::SIDEBOARD) {
|
} else if (zoneName == ZoneNames::SIDEBOARD) {
|
||||||
fromStr = tr(" from sideboard");
|
fromStr = tr("from sideboard");
|
||||||
} else if (zoneName == ZoneNames::STACK) {
|
} else if (zoneName == ZoneNames::STACK) {
|
||||||
fromStr = tr(" from the stack");
|
fromStr = tr("from the stack");
|
||||||
} else {
|
} else {
|
||||||
fromStr = tr(" from custom zone '%1'").arg(zoneName);
|
fromStr = tr("from custom zone '%1'").arg(zoneName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cardNameContainsStartZone) {
|
if (!cardNameContainsStartZone) {
|
||||||
cardName.clear();
|
cardName.clear();
|
||||||
}
|
}
|
||||||
|
// The separator space is added here rather than stored in the tr() string so
|
||||||
|
// translators never have to preserve an invisible leading blank.
|
||||||
|
if (!fromStr.isEmpty()) {
|
||||||
|
fromStr.prepend(' ');
|
||||||
|
}
|
||||||
return {cardName, fromStr};
|
return {cardName, fromStr};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts
|
||||||
|
|
||||||
aRevealToAll = new QAction(this);
|
aRevealToAll = new QAction(this);
|
||||||
|
|
||||||
mCardCounters = new QMenu;
|
mCardCounters = new QMenu(this);
|
||||||
|
|
||||||
// Card counters
|
// Card counters
|
||||||
for (int i = 0; i < 6; ++i) {
|
for (int i = 0; i < 6; ++i) {
|
||||||
|
|
@ -225,7 +225,7 @@ void CardMenu::createTableMenu(bool canModifyCard)
|
||||||
}
|
}
|
||||||
addSeparator();
|
addSeparator();
|
||||||
addAction(aClone);
|
addAction(aClone);
|
||||||
addMenu(new MoveMenu(player));
|
addMenu(new MoveMenu(player, this));
|
||||||
addSeparator();
|
addSeparator();
|
||||||
addAction(aAttach);
|
addAction(aAttach);
|
||||||
if (card->getAttachedTo()) {
|
if (card->getAttachedTo()) {
|
||||||
|
|
@ -233,7 +233,7 @@ void CardMenu::createTableMenu(bool canModifyCard)
|
||||||
}
|
}
|
||||||
addAction(aDrawArrow);
|
addAction(aDrawArrow);
|
||||||
addSeparator();
|
addSeparator();
|
||||||
addMenu(new PtMenu(player));
|
addMenu(new PtMenu(player, this));
|
||||||
addAction(aSetAnnotation);
|
addAction(aSetAnnotation);
|
||||||
addSeparator();
|
addSeparator();
|
||||||
addAction(aReduceLifeByPower);
|
addAction(aReduceLifeByPower);
|
||||||
|
|
@ -275,7 +275,7 @@ void CardMenu::createStackMenu(bool canModifyCard)
|
||||||
addAction(aPlayFacedown);
|
addAction(aPlayFacedown);
|
||||||
addSeparator();
|
addSeparator();
|
||||||
addAction(aClone);
|
addAction(aClone);
|
||||||
addMenu(new MoveMenu(player));
|
addMenu(new MoveMenu(player, this));
|
||||||
addSeparator();
|
addSeparator();
|
||||||
addAction(aAttach);
|
addAction(aAttach);
|
||||||
addAction(aDrawArrow);
|
addAction(aDrawArrow);
|
||||||
|
|
@ -304,7 +304,7 @@ void CardMenu::createGraveyardOrExileMenu(bool canModifyCard)
|
||||||
addAction(aPlayFacedown);
|
addAction(aPlayFacedown);
|
||||||
addSeparator();
|
addSeparator();
|
||||||
addAction(aClone);
|
addAction(aClone);
|
||||||
addMenu(new MoveMenu(player));
|
addMenu(new MoveMenu(player, this));
|
||||||
addSeparator();
|
addSeparator();
|
||||||
addAction(aAttach);
|
addAction(aAttach);
|
||||||
addAction(aDrawArrow);
|
addAction(aDrawArrow);
|
||||||
|
|
@ -342,7 +342,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard)
|
||||||
|
|
||||||
addSeparator();
|
addSeparator();
|
||||||
addAction(aClone);
|
addAction(aClone);
|
||||||
addMenu(new MoveMenu(player));
|
addMenu(new MoveMenu(player, this));
|
||||||
|
|
||||||
// actions that are really wonky when done from deck or sideboard
|
// actions that are really wonky when done from deck or sideboard
|
||||||
if (card->getZone()->getName() == ZoneNames::HAND) {
|
if (card->getZone()->getName() == ZoneNames::HAND) {
|
||||||
|
|
@ -366,7 +366,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard)
|
||||||
void CardMenu::createZonelessMenu(bool canModifyCard)
|
void CardMenu::createZonelessMenu(bool canModifyCard)
|
||||||
{
|
{
|
||||||
if (canModifyCard) {
|
if (canModifyCard) {
|
||||||
addMenu(new MoveMenu(player));
|
addMenu(new MoveMenu(player, this));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -415,7 +415,7 @@ void CardMenu::addRelatedCardView()
|
||||||
}
|
}
|
||||||
|
|
||||||
addSeparator();
|
addSeparator();
|
||||||
auto viewRelatedCards = new QMenu(tr("View related cards"));
|
auto viewRelatedCards = new QMenu(tr("View related cards"), this);
|
||||||
addMenu(viewRelatedCards);
|
addMenu(viewRelatedCards);
|
||||||
for (const CardRelation *relatedCard : relatedCards) {
|
for (const CardRelation *relatedCard : relatedCards) {
|
||||||
QString relatedCardName = relatedCard->getName();
|
QString relatedCardName = relatedCard->getName();
|
||||||
|
|
@ -463,16 +463,16 @@ void CardMenu::addRelatedCardActions()
|
||||||
relatedCardName = relatedCard.getName(); // "name"
|
relatedCardName = relatedCard.getName(); // "name"
|
||||||
}
|
}
|
||||||
|
|
||||||
QString text = tr("Token: ");
|
QString text;
|
||||||
if (cardRelation->getDoesAttach()) {
|
if (cardRelation->getDoesAttach()) {
|
||||||
text +=
|
text = cardRelation->getDoesTransform() ? tr("Token: Transform into \"%1\"").arg(relatedCardName)
|
||||||
tr(cardRelation->getDoesTransform() ? "Transform into " : "Attach to ") + "\"" + relatedCardName + "\"";
|
: tr("Token: Attach to \"%1\"").arg(relatedCardName);
|
||||||
} else if (cardRelation->getIsVariable()) {
|
} else if (cardRelation->getIsVariable()) {
|
||||||
text += "X " + relatedCardName;
|
text = tr("Token: X %1").arg(relatedCardName);
|
||||||
} else if (cardRelation->getDefaultCount() != 1) {
|
} else if (cardRelation->getDefaultCount() != 1) {
|
||||||
text += QString::number(cardRelation->getDefaultCount()) + "x " + relatedCardName;
|
text = tr("Token: %1x %2").arg(cardRelation->getDefaultCount()).arg(relatedCardName);
|
||||||
} else {
|
} else {
|
||||||
text += relatedCardName;
|
text = tr("Token: %1").arg(relatedCardName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (createRelatedCards == nullptr) {
|
if (createRelatedCards == nullptr) {
|
||||||
|
|
@ -587,4 +587,4 @@ void CardMenu::setShortcutsActive()
|
||||||
aRemoveCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aRC" + colorWords[i]));
|
aRemoveCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aRC" + colorWords[i]));
|
||||||
aSetCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aSC" + colorWords[i]));
|
aSetCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aSC" + colorWords[i]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
#include <libcockatrice/game/player/player_actions.h>
|
#include <libcockatrice/game/player/player_actions.h>
|
||||||
#include <libcockatrice/game/player/player_logic.h>
|
#include <libcockatrice/game/player/player_logic.h>
|
||||||
|
|
||||||
MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to"))
|
MoveMenu::MoveMenu(PlayerGraphicsItem *player, QWidget *parent) : QMenu(tr("Move to"), parent)
|
||||||
{
|
{
|
||||||
aMoveToTopLibrary = new QAction(this);
|
aMoveToTopLibrary = new QAction(this);
|
||||||
aMoveToTopLibrary->setData(cmMoveToTopLibrary);
|
aMoveToTopLibrary->setData(cmMoveToTopLibrary);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ class MoveMenu : public QMenu
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit MoveMenu(PlayerGraphicsItem *player);
|
explicit MoveMenu(PlayerGraphicsItem *player, QWidget *parent);
|
||||||
void setShortcutsActive();
|
void setShortcutsActive();
|
||||||
void retranslateUi();
|
void retranslateUi();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <libcockatrice/game/abstract_game.h>
|
#include <libcockatrice/game/abstract_game.h>
|
||||||
|
#include <libcockatrice/game/game_meta_info.h>
|
||||||
#include <libcockatrice/game/player/player_info.h>
|
#include <libcockatrice/game/player/player_info.h>
|
||||||
#include <libcockatrice/game/player/player_logic.h>
|
#include <libcockatrice/game/player/player_logic.h>
|
||||||
#include <libcockatrice/game/player/player_manager.h>
|
#include <libcockatrice/game/player/player_manager.h>
|
||||||
|
|
@ -70,6 +71,11 @@ PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_
|
||||||
|
|
||||||
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
|
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
|
||||||
&PlayerMenu::refreshShortcuts);
|
&PlayerMenu::refreshShortcuts);
|
||||||
|
|
||||||
|
// Re-evaluate shortcuts when the game starts or ends, so the lobby only exposes the roll-die shortcut.
|
||||||
|
connect(player->getLogic()->getGame()->getGameMetaInfo(), &GameMetaInfo::startedChanged, this,
|
||||||
|
&PlayerMenu::refreshShortcuts);
|
||||||
|
|
||||||
refreshShortcuts();
|
refreshShortcuts();
|
||||||
|
|
||||||
retranslateUi();
|
retranslateUi();
|
||||||
|
|
@ -144,6 +150,12 @@ void PlayerMenu::refreshShortcuts()
|
||||||
void PlayerMenu::setShortcutsActive()
|
void PlayerMenu::setShortcutsActive()
|
||||||
{
|
{
|
||||||
shortcutsActive = true;
|
shortcutsActive = true;
|
||||||
|
|
||||||
|
if (!isGameStarted()) {
|
||||||
|
applyLobbyShortcuts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (auto *c : managedComponents) {
|
for (auto *c : managedComponents) {
|
||||||
c->setShortcutsActive();
|
c->setShortcutsActive();
|
||||||
}
|
}
|
||||||
|
|
@ -156,5 +168,24 @@ void PlayerMenu::setShortcutsInactive()
|
||||||
for (auto *c : managedComponents) {
|
for (auto *c : managedComponents) {
|
||||||
c->setShortcutsInactive();
|
c->setShortcutsInactive();
|
||||||
}
|
}
|
||||||
|
emit shortcutsDeactivated();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PlayerMenu::isGameStarted() const
|
||||||
|
{
|
||||||
|
return player->getLogic()->getGame()->getGameMetaInfo()->started();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerMenu::applyLobbyShortcuts()
|
||||||
|
{
|
||||||
|
// Everything stays unbound until the game starts; only the roll-die shortcut is usable in the lobby.
|
||||||
|
for (auto *c : managedComponents) {
|
||||||
|
c->setShortcutsInactive();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (utilityMenu) {
|
||||||
|
utilityMenu->setLobbyShortcutsActive();
|
||||||
|
}
|
||||||
|
|
||||||
emit shortcutsDeactivated();
|
emit shortcutsDeactivated();
|
||||||
}
|
}
|
||||||
|
|
@ -73,17 +73,24 @@ public:
|
||||||
return utilityMenu;
|
return utilityMenu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @brief Whether this player currently holds its shortcuts and the game has started. */
|
||||||
[[nodiscard]] bool getShortcutsActive() const
|
[[nodiscard]] bool getShortcutsActive() const
|
||||||
{
|
{
|
||||||
return shortcutsActive;
|
return shortcutsActive && isGameStarted();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @brief Bind keyboard shortcuts. Called when this player gains focus. */
|
/** @brief Bind keyboard shortcuts. Only the roll-die shortcut is bound before the game starts. */
|
||||||
void setShortcutsActive();
|
void setShortcutsActive();
|
||||||
/** @brief Unbind keyboard shortcuts. Called when this player loses focus. */
|
/** @brief Unbind keyboard shortcuts. Called when this player loses focus. */
|
||||||
void setShortcutsInactive();
|
void setShortcutsInactive();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
/** @brief True once the game has started; false while players are still in the deck-selection lobby. */
|
||||||
|
[[nodiscard]] bool isGameStarted() const;
|
||||||
|
|
||||||
|
/** @brief Unbinds everything except the shortcuts that remain usable before the game starts. */
|
||||||
|
void applyLobbyShortcuts();
|
||||||
|
|
||||||
PlayerGraphicsItem *player;
|
PlayerGraphicsItem *player;
|
||||||
TearOffMenu *playerMenu;
|
TearOffMenu *playerMenu;
|
||||||
QMenu *countersMenu;
|
QMenu *countersMenu;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
#include <libcockatrice/game/player/player_actions.h>
|
#include <libcockatrice/game/player/player_actions.h>
|
||||||
#include <libcockatrice/game/player/player_logic.h>
|
#include <libcockatrice/game/player/player_logic.h>
|
||||||
|
|
||||||
PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness"))
|
PtMenu::PtMenu(PlayerGraphicsItem *player, QWidget *parent) : QMenu(tr("Power / toughness"), parent)
|
||||||
{
|
{
|
||||||
PlayerActions *playerActions = player->getLogic()->getPlayerActions();
|
PlayerActions *playerActions = player->getLogic()->getPlayerActions();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class PtMenu : public QMenu
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit PtMenu(PlayerGraphicsItem *player);
|
explicit PtMenu(PlayerGraphicsItem *player, QWidget *parent);
|
||||||
void retranslateUi();
|
void retranslateUi();
|
||||||
void setShortcutsActive();
|
void setShortcutsActive();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,4 +150,13 @@ void UtilityMenu::setShortcutsInactive()
|
||||||
aCreateAnotherToken->setShortcut(QKeySequence());
|
aCreateAnotherToken->setShortcut(QKeySequence());
|
||||||
aIncrementAllCardCounters->setShortcut(QKeySequence());
|
aIncrementAllCardCounters->setShortcut(QKeySequence());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void UtilityMenu::setLobbyShortcutsActive()
|
||||||
|
{
|
||||||
|
if (!player->getLogic()->getPlayerInfo()->getLocalOrJudge()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
aRollDie->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("Player/aRollDie"));
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +32,9 @@ public slots:
|
||||||
public:
|
public:
|
||||||
explicit UtilityMenu(PlayerGraphicsItem *player, QMenu *playerMenu);
|
explicit UtilityMenu(PlayerGraphicsItem *player, QMenu *playerMenu);
|
||||||
|
|
||||||
|
/** @brief Binds only the shortcuts that remain usable before the game starts. */
|
||||||
|
void setLobbyShortcutsActive();
|
||||||
|
|
||||||
[[nodiscard]] bool createAnotherTokenActionExists() const
|
[[nodiscard]] bool createAnotherTokenActionExists() const
|
||||||
{
|
{
|
||||||
return aCreateAnotherToken != nullptr;
|
return aCreateAnotherToken != nullptr;
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,27 @@
|
||||||
#include "view_zone.h"
|
#include "view_zone.h"
|
||||||
|
|
||||||
#include "../../client/settings/cache_settings.h"
|
#include "../../client/settings/cache_settings.h"
|
||||||
|
#include "../../interface/theme_manager.h"
|
||||||
#include "../board/card_drag_item.h"
|
#include "../board/card_drag_item.h"
|
||||||
#include "../board/card_item.h"
|
#include "../board/card_item.h"
|
||||||
#include "../card_dimensions.h"
|
#include "../card_dimensions.h"
|
||||||
|
#include "../z_values.h"
|
||||||
#include "libcockatrice/card/printing/exact_card.h"
|
#include "libcockatrice/card/printing/exact_card.h"
|
||||||
#include "libcockatrice/filters/filter_string.h"
|
#include "libcockatrice/filters/filter_string.h"
|
||||||
#include "libcockatrice/protocol/pb/response.pb.h"
|
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||||
#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h"
|
#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h"
|
||||||
#include "libcockatrice/utility/card_ref.h"
|
#include "libcockatrice/utility/card_ref.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
#include <QBrush>
|
#include <QBrush>
|
||||||
#include <QColor>
|
#include <QColor>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QFontMetrics>
|
||||||
#include <QMessageLogger>
|
#include <QMessageLogger>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
|
#include <QPalette>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
#include <QtMath>
|
#include <QtMath>
|
||||||
|
|
@ -34,12 +40,85 @@
|
||||||
|
|
||||||
class QGraphicsItem;
|
class QGraphicsItem;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr qreal MARKER_HORIZONTAL_PADDING = 4.0;
|
||||||
|
constexpr qreal MARKER_VERTICAL_PADDING = 2.0;
|
||||||
|
constexpr qreal MARKER_CORNER_RADIUS = 4.0;
|
||||||
|
constexpr qreal MARKER_INSET = 2.0;
|
||||||
|
constexpr int MARKER_FONT_SIZE = 9;
|
||||||
|
// Vertical pitch of a card column: each card shows this much before the next
|
||||||
|
// card overlaps it. Marker placement uses it to stay on the visible strip.
|
||||||
|
constexpr qreal CARD_STACK_OFFSET = CardDimensions::HEIGHT_F / 3.0;
|
||||||
|
|
||||||
|
QFont markerFont()
|
||||||
|
{
|
||||||
|
QFont font = themeManager != nullptr ? themeManager->appFont(AppFont::CardText) : QFont();
|
||||||
|
font.setPixelSize(MARKER_FONT_SIZE);
|
||||||
|
font.setWeight(QFont::Bold);
|
||||||
|
return font;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
LibraryEdgeMarker::LibraryEdgeMarker(QGraphicsItem *parent) : QGraphicsItem(parent)
|
||||||
|
{
|
||||||
|
setAcceptedMouseButtons(Qt::NoButton);
|
||||||
|
setAcceptHoverEvents(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LibraryEdgeMarker::setLabel(const QString &newLabel, const QString &toolTip)
|
||||||
|
{
|
||||||
|
if (label == newLabel && this->toolTip() == toolTip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareGeometryChange();
|
||||||
|
label = newLabel;
|
||||||
|
|
||||||
|
const QFontMetrics metrics(markerFont());
|
||||||
|
markerRect = QRectF(0, 0, metrics.horizontalAdvance(label) + 2 * MARKER_HORIZONTAL_PADDING,
|
||||||
|
metrics.height() + 2 * MARKER_VERTICAL_PADDING);
|
||||||
|
|
||||||
|
setToolTip(toolTip);
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
QRectF LibraryEdgeMarker::boundingRect() const
|
||||||
|
{
|
||||||
|
return markerRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
void LibraryEdgeMarker::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
|
||||||
|
{
|
||||||
|
if (label.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
painter->save();
|
||||||
|
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||||
|
painter->setFont(markerFont());
|
||||||
|
|
||||||
|
// ToolTipBase/ToolTipText are the palette's designed background/text pair
|
||||||
|
// for small overlays, so the chip keeps its contrast in every theme.
|
||||||
|
const QPalette palette = qApp->palette();
|
||||||
|
painter->setPen(QPen(palette.color(QPalette::Mid), 1));
|
||||||
|
painter->setBrush(palette.color(QPalette::ToolTipBase));
|
||||||
|
painter->drawRoundedRect(markerRect.adjusted(0.5, 0.5, -0.5, -0.5), MARKER_CORNER_RADIUS, MARKER_CORNER_RADIUS);
|
||||||
|
|
||||||
|
painter->setPen(palette.color(QPalette::ToolTipText));
|
||||||
|
painter->drawText(markerRect, Qt::AlignCenter, label);
|
||||||
|
painter->restore();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param parent the parent QGraphicsWidget containing the reveal zone
|
* @param parent the parent QGraphicsWidget containing the reveal zone
|
||||||
*/
|
*/
|
||||||
ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent)
|
ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent)
|
||||||
: SelectZone(_logic, parent), bRect(QRectF()), minRows(0), groupBy(CardList::NoSort), sortBy(CardList::NoSort)
|
: SelectZone(_logic, parent), bRect(QRectF()), minRows(0), groupBy(CardList::NoSort), sortBy(CardList::NoSort)
|
||||||
{
|
{
|
||||||
|
libraryEdgeMarker = new LibraryEdgeMarker(this);
|
||||||
|
libraryEdgeMarker->setVisible(false);
|
||||||
|
|
||||||
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
|
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
|
||||||
if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) {
|
if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) {
|
||||||
viewLogic->getOriginalZone()->getViews().append(viewLogic);
|
viewLogic->getOriginalZone()->getViews().append(viewLogic);
|
||||||
|
|
@ -183,6 +262,8 @@ void ZoneViewZone::reorganizeCards()
|
||||||
gridSize = positionCardsForDisplay(cardsToDisplay);
|
gridSize = positionCardsForDisplay(cardsToDisplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateLibraryEdgeMarker(cardsToDisplay);
|
||||||
|
|
||||||
// determine bounding rect
|
// determine bounding rect
|
||||||
qreal aleft = 0;
|
qreal aleft = 0;
|
||||||
qreal atop = 0;
|
qreal atop = 0;
|
||||||
|
|
@ -232,7 +313,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
|
||||||
|
|
||||||
lastColumnProp = columnProp;
|
lastColumnProp = columnProp;
|
||||||
qreal x = col * CardDimensions::WIDTH_F;
|
qreal x = col * CardDimensions::WIDTH_F;
|
||||||
qreal y = row * CardDimensions::HEIGHT_F / 3;
|
qreal y = row * CARD_STACK_OFFSET;
|
||||||
cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y);
|
cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y);
|
||||||
cItem->setRealZValue(i);
|
cItem->setRealZValue(i);
|
||||||
longestRow = qMax(row, longestRow);
|
longestRow = qMax(row, longestRow);
|
||||||
|
|
@ -261,7 +342,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
|
||||||
for (int i = 0; i < cardCount; i++) {
|
for (int i = 0; i < cardCount; i++) {
|
||||||
auto *cItem = qobject_cast<CardItem *>(cards.at(i)->parent());
|
auto *cItem = qobject_cast<CardItem *>(cards.at(i)->parent());
|
||||||
qreal x = (i / rows) * CardDimensions::WIDTH_F;
|
qreal x = (i / rows) * CardDimensions::WIDTH_F;
|
||||||
qreal y = (i % rows) * CardDimensions::HEIGHT_F / 3;
|
qreal y = (i % rows) * CARD_STACK_OFFSET;
|
||||||
cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y);
|
cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y);
|
||||||
cItem->setRealZValue(i);
|
cItem->setRealZValue(i);
|
||||||
}
|
}
|
||||||
|
|
@ -270,6 +351,75 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ZoneViewZone::updateLibraryEdgeMarker(const CardList &displayedCards)
|
||||||
|
{
|
||||||
|
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
|
||||||
|
if (viewLogic == nullptr || !viewLogic->isLibraryEdgeView() || displayedCards.isEmpty()) {
|
||||||
|
libraryEdgeMarkerAnchorPos = QPointF();
|
||||||
|
libraryEdgeMarker->hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool topEdge = viewLogic->showsTopOfLibrary();
|
||||||
|
CardState *edgeState = topEdge ? displayedCards.first() : displayedCards.last();
|
||||||
|
auto *edgeCard = edgeState == nullptr ? nullptr : qobject_cast<CardItem *>(edgeState->parent());
|
||||||
|
if (edgeCard == nullptr) {
|
||||||
|
libraryEdgeMarkerAnchorPos = QPointF();
|
||||||
|
libraryEdgeMarker->hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryEdgeMarkerAnchorPos = edgeCard->pos();
|
||||||
|
applyLibraryEdgeMarkerText();
|
||||||
|
positionLibraryEdgeMarker();
|
||||||
|
|
||||||
|
// Above the hovered-card layer: the chip sits inside its card, so hovering
|
||||||
|
// that card must not let the card repaint over the indicator.
|
||||||
|
libraryEdgeMarker->setZValue(ZValues::HOVERED_CARD + 0.5);
|
||||||
|
libraryEdgeMarker->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ZoneViewZone::positionLibraryEdgeMarker()
|
||||||
|
{
|
||||||
|
// The chip has to stay on the part of its card that is not covered by the
|
||||||
|
// next card in the column. The top card only shows its top strip, and its
|
||||||
|
// name is drawn top-left, so the chip goes to the right of that strip. The
|
||||||
|
// bottom card is fully visible, so the chip goes to its bottom-left, away
|
||||||
|
// from the bottom-right power/toughness.
|
||||||
|
const auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
|
||||||
|
const QRectF markerRect = libraryEdgeMarker->boundingRect();
|
||||||
|
const bool topEdge = viewLogic != nullptr && viewLogic->showsTopOfLibrary();
|
||||||
|
const QPointF offset = topEdge
|
||||||
|
? QPointF(CardDimensions::WIDTH_F - markerRect.width() - MARKER_INSET,
|
||||||
|
CARD_STACK_OFFSET - markerRect.height() - MARKER_INSET)
|
||||||
|
: QPointF(MARKER_INSET, CardDimensions::HEIGHT_F - markerRect.height() - MARKER_INSET);
|
||||||
|
libraryEdgeMarker->setPos(libraryEdgeMarkerAnchorPos + offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ZoneViewZone::applyLibraryEdgeMarkerText()
|
||||||
|
{
|
||||||
|
const auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
|
||||||
|
if (viewLogic == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool topEdge = viewLogic->showsTopOfLibrary();
|
||||||
|
//: Short badge on the card at the top or bottom of a viewed library. The full phrase is in the tooltip.
|
||||||
|
libraryEdgeMarker->setLabel(topEdge ? tr("Top") : tr("Bottom"),
|
||||||
|
topEdge ? tr("This card is on top of the library")
|
||||||
|
: tr("This card is at the bottom of the library"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ZoneViewZone::retranslateEdgeMarker()
|
||||||
|
{
|
||||||
|
if (libraryEdgeMarker->isVisible()) {
|
||||||
|
// The translated label can be a different width, so reposition the chip
|
||||||
|
// to keep it inside its card.
|
||||||
|
applyLibraryEdgeMarkerText();
|
||||||
|
positionLibraryEdgeMarker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ZoneViewZone::setFilterString(const QString &_filterString)
|
void ZoneViewZone::setFilterString(const QString &_filterString)
|
||||||
{
|
{
|
||||||
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
|
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@
|
||||||
|
|
||||||
#include "select_zone.h"
|
#include "select_zone.h"
|
||||||
|
|
||||||
|
#include <QGraphicsItem>
|
||||||
#include <QGraphicsLayoutItem>
|
#include <QGraphicsLayoutItem>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QLoggingCategory>
|
#include <QLoggingCategory>
|
||||||
|
#include <QPointF>
|
||||||
#include <QRectF>
|
#include <QRectF>
|
||||||
#include <QSizeF>
|
#include <QSizeF>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
@ -33,6 +35,30 @@ inline Q_LOGGING_CATEGORY(ViewZoneLog, "view_zone");
|
||||||
class Response;
|
class Response;
|
||||||
class ServerInfo_Card;
|
class ServerInfo_Card;
|
||||||
class QGraphicsSceneWheelEvent;
|
class QGraphicsSceneWheelEvent;
|
||||||
|
class QPainter;
|
||||||
|
class QStyleOptionGraphicsItem;
|
||||||
|
class QWidget;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small chip drawn on the card at the edge of a viewed library.
|
||||||
|
*
|
||||||
|
* It is a sibling of the card items rather than part of the zone widget, so it
|
||||||
|
* stays attached to its card when the view reflows into several columns or is
|
||||||
|
* resized. ZoneViewZone owns and positions it.
|
||||||
|
*/
|
||||||
|
class LibraryEdgeMarker : public QGraphicsItem
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit LibraryEdgeMarker(QGraphicsItem *parent = nullptr);
|
||||||
|
|
||||||
|
void setLabel(const QString &label, const QString &toolTip);
|
||||||
|
[[nodiscard]] QRectF boundingRect() const override;
|
||||||
|
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
QRectF markerRect;
|
||||||
|
QString label;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A CardZone that is a view into another CardZone.
|
* A CardZone that is a view into another CardZone.
|
||||||
|
|
@ -56,6 +82,8 @@ private:
|
||||||
FilterString filterString = FilterString("");
|
FilterString filterString = FilterString("");
|
||||||
CardList::SortOption groupBy, sortBy;
|
CardList::SortOption groupBy, sortBy;
|
||||||
bool pileView;
|
bool pileView;
|
||||||
|
LibraryEdgeMarker *libraryEdgeMarker;
|
||||||
|
QPointF libraryEdgeMarkerAnchorPos;
|
||||||
|
|
||||||
struct GridSize
|
struct GridSize
|
||||||
{
|
{
|
||||||
|
|
@ -64,6 +92,9 @@ private:
|
||||||
};
|
};
|
||||||
|
|
||||||
GridSize positionCardsForDisplay(CardList &cards, CardList::SortOption pileOption = CardList::NoSort);
|
GridSize positionCardsForDisplay(CardList &cards, CardList::SortOption pileOption = CardList::NoSort);
|
||||||
|
void updateLibraryEdgeMarker(const CardList &displayedCards);
|
||||||
|
void applyLibraryEdgeMarkerText();
|
||||||
|
void positionLibraryEdgeMarker();
|
||||||
|
|
||||||
void
|
void
|
||||||
handleDropEvent(const QList<CardDragItem *> &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override;
|
handleDropEvent(const QList<CardDragItem *> &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override;
|
||||||
|
|
@ -79,6 +110,10 @@ public:
|
||||||
{
|
{
|
||||||
return optimumRect;
|
return optimumRect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @brief Re-applies the translated library edge marker text after a language change. */
|
||||||
|
void retranslateEdgeMarker();
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
void addToViews();
|
void addToViews();
|
||||||
void removeFromViews();
|
void removeFromViews();
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,7 @@ void ZoneViewWidget::retranslateUi()
|
||||||
|
|
||||||
shuffleCheckBox.setText(tr("shuffle when closing"));
|
shuffleCheckBox.setText(tr("shuffle when closing"));
|
||||||
pileViewCheckBox.setText(tr("pile view"));
|
pileViewCheckBox.setText(tr("pile view"));
|
||||||
|
zone->retranslateEdgeMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ZoneViewWidget::stopWindowDrag()
|
void ZoneViewWidget::stopWindowDrag()
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
|
||||||
|
|
||||||
QNetworkRequest req(url);
|
QNetworkRequest req(url);
|
||||||
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
|
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
|
||||||
req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8");
|
req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
|
||||||
|
|
||||||
// Cached entries are served straight from the disk cache even when picture downloads are
|
// Cached entries are served straight from the disk cache even when picture downloads are
|
||||||
// enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a
|
// enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ DlgForgotPasswordReset::DlgForgotPasswordReset(QWidget *parent) : QDialog(parent
|
||||||
playernameEdit->setMaxLength(MAX_NAME_LENGTH);
|
playernameEdit->setMaxLength(MAX_NAME_LENGTH);
|
||||||
playernameLabel->setBuddy(playernameEdit);
|
playernameLabel->setBuddy(playernameEdit);
|
||||||
|
|
||||||
|
//: Code mailed to the player so they can reset their password. Not a game token.
|
||||||
tokenLabel = new QLabel(tr("Token:"));
|
tokenLabel = new QLabel(tr("Token:"));
|
||||||
tokenEdit = new QLineEdit();
|
tokenEdit = new QLineEdit();
|
||||||
tokenEdit->setMaxLength(MAX_NAME_LENGTH);
|
tokenEdit->setMaxLength(MAX_NAME_LENGTH);
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,8 @@ void DlgMyReports::onSelectionChanged()
|
||||||
connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse);
|
connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse);
|
||||||
client->sendCommand(pend);
|
client->sendCommand(pend);
|
||||||
|
|
||||||
QString status = table->item(row, COL_STATUS)->text();
|
// The cell text is the translated label; the raw status lives in UserRole.
|
||||||
|
const QString status = table->item(row, COL_STATUS)->data(Qt::UserRole).toString();
|
||||||
bool canComment = (status == "open" || status == "assigned");
|
bool canComment = (status == "open" || status == "assigned");
|
||||||
commentButton->setEnabled(canComment);
|
commentButton->setEnabled(canComment);
|
||||||
commentInput->setEnabled(canComment);
|
commentInput->setEnabled(canComment);
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,14 @@
|
||||||
#include <QCheckBox>
|
#include <QCheckBox>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
|
#include <QHideEvent>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
#include <QScrollArea>
|
#include <QScrollArea>
|
||||||
#include <QScrollBar>
|
#include <QScrollBar>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
#include <QSizePolicy>
|
#include <QSizePolicy>
|
||||||
|
#include <QShowEvent>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
@ -54,6 +56,9 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck
|
||||||
setLayout(layout);
|
setLayout(layout);
|
||||||
|
|
||||||
widgetLoadingBufferTimer = new QTimer(this);
|
widgetLoadingBufferTimer = new QTimer(this);
|
||||||
|
deckChangeTimer = new QTimer(this);
|
||||||
|
deckChangeTimer->setSingleShot(true);
|
||||||
|
connect(deckChangeTimer, &QTimer::timeout, this, &PrintingSelector::updateDisplay);
|
||||||
|
|
||||||
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded);
|
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded);
|
||||||
|
|
||||||
|
|
@ -122,11 +127,36 @@ void PrintingSelector::retranslateUi()
|
||||||
void PrintingSelector::printingsInDeckChanged()
|
void PrintingSelector::printingsInDeckChanged()
|
||||||
{
|
{
|
||||||
if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) {
|
if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) {
|
||||||
// Delay the update to avoid race conditions
|
displayDirty = true;
|
||||||
QTimer::singleShot(100, this, &PrintingSelector::updateDisplay);
|
if (isVisible()) {
|
||||||
|
// Coalesce deck changes while the model settles.
|
||||||
|
deckChangeTimer->start(100);
|
||||||
|
} else {
|
||||||
|
widgetLoadingBufferTimer->stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void PrintingSelector::showEvent(QShowEvent *event)
|
||||||
|
{
|
||||||
|
QWidget::showEvent(event);
|
||||||
|
if (displayDirty) {
|
||||||
|
updateDisplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PrintingSelector::hideEvent(QHideEvent *event)
|
||||||
|
{
|
||||||
|
// A partially built batch must be rebuilt on reveal, not resumed with
|
||||||
|
// stale printing data after the selected card or deck changes.
|
||||||
|
if (widgetLoadingBufferTimer->isActive() || deckChangeTimer->isActive()) {
|
||||||
|
widgetLoadingBufferTimer->stop();
|
||||||
|
deckChangeTimer->stop();
|
||||||
|
displayDirty = true;
|
||||||
|
}
|
||||||
|
QWidget::hideEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return A map of uuid to amounts (main, side, tokens).
|
* @return A map of uuid to amounts (main, side, tokens).
|
||||||
*/
|
*/
|
||||||
|
|
@ -173,6 +203,8 @@ void PrintingSelector::updateCardAmounts()
|
||||||
*/
|
*/
|
||||||
void PrintingSelector::updateDisplay()
|
void PrintingSelector::updateDisplay()
|
||||||
{
|
{
|
||||||
|
deckChangeTimer->stop();
|
||||||
|
displayDirty = false;
|
||||||
widgetLoadingBufferTimer->stop();
|
widgetLoadingBufferTimer->stop();
|
||||||
widgetLoadingBufferTimer->deleteLater();
|
widgetLoadingBufferTimer->deleteLater();
|
||||||
widgetLoadingBufferTimer = new QTimer(this);
|
widgetLoadingBufferTimer = new QTimer(this);
|
||||||
|
|
@ -199,20 +231,18 @@ void PrintingSelector::updateDisplay()
|
||||||
*/
|
*/
|
||||||
void PrintingSelector::setCard(const CardInfoPtr &newCard)
|
void PrintingSelector::setCard(const CardInfoPtr &newCard)
|
||||||
{
|
{
|
||||||
if (newCard.isNull()) {
|
// Same-name cards may be different database snapshots after a reload.
|
||||||
selectedCard = newCard;
|
if (selectedCard == newCard) {
|
||||||
updateDisplay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// we don't need to redraw the widget if the card is the same
|
|
||||||
if (!selectedCard.isNull() && selectedCard->getName() == newCard->getName()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
widgetLoadingBufferTimer->stop();
|
||||||
|
deckChangeTimer->stop();
|
||||||
selectedCard = newCard;
|
selectedCard = newCard;
|
||||||
if (isVisible()) {
|
if (isVisible()) {
|
||||||
updateDisplay();
|
updateDisplay();
|
||||||
|
} else {
|
||||||
|
displayDirty = true;
|
||||||
}
|
}
|
||||||
flowWidget->setMinimumSizeToMaxSizeHint();
|
flowWidget->setMinimumSizeToMaxSizeHint();
|
||||||
flowWidget->scrollArea->verticalScrollBar()->setValue(0);
|
flowWidget->scrollArea->verticalScrollBar()->setValue(0);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ class QCheckBox;
|
||||||
class QHBoxLayout;
|
class QHBoxLayout;
|
||||||
class QString;
|
class QString;
|
||||||
class QTimer;
|
class QTimer;
|
||||||
|
class QShowEvent;
|
||||||
|
class QHideEvent;
|
||||||
class QVBoxLayout;
|
class QVBoxLayout;
|
||||||
class SettingsButtonWidget;
|
class SettingsButtonWidget;
|
||||||
template <class Key, class T> class QMap;
|
template <class Key, class T> class QMap;
|
||||||
|
|
@ -47,6 +49,10 @@ public:
|
||||||
void setCard(const CardInfoPtr &newCard);
|
void setCard(const CardInfoPtr &newCard);
|
||||||
void getAllSetsForCurrentCard();
|
void getAllSetsForCurrentCard();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void showEvent(QShowEvent *event) override;
|
||||||
|
void hideEvent(QHideEvent *event) override;
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
void retranslateUi();
|
void retranslateUi();
|
||||||
void updateDisplay();
|
void updateDisplay();
|
||||||
|
|
@ -88,6 +94,8 @@ private:
|
||||||
DeckStateManager *deckStateManager;
|
DeckStateManager *deckStateManager;
|
||||||
CardInfoPtr selectedCard;
|
CardInfoPtr selectedCard;
|
||||||
QTimer *widgetLoadingBufferTimer;
|
QTimer *widgetLoadingBufferTimer;
|
||||||
|
QTimer *deckChangeTimer;
|
||||||
|
bool displayDirty = false;
|
||||||
int currentIndex = 0;
|
int currentIndex = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -266,9 +266,13 @@ void GameSelector::actCreate()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DlgCreateGame dlg(room, room->getGameTypes(), this);
|
// Heap-allocate the dialog: if this selector (or its room tab) is destroyed while the
|
||||||
dlg.exec();
|
// dialog is open, e.g. on server disconnect, Qt deletes its children. A stack-allocated
|
||||||
updateTitle();
|
// child would then be deleted by Qt and crash.
|
||||||
|
auto *dlg = new DlgCreateGame(room, room->getGameTypes(), this);
|
||||||
|
dlg->setAttribute(Qt::WA_DeleteOnClose);
|
||||||
|
connect(dlg, &QDialog::finished, this, &GameSelector::updateTitle);
|
||||||
|
dlg->exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameSelector::checkResponse(const Response &response)
|
void GameSelector::checkResponse(const Response &response)
|
||||||
|
|
@ -362,8 +366,9 @@ void GameSelector::customContextMenu(const QPoint &point)
|
||||||
const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt());
|
const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt());
|
||||||
const QMap<int, QString> &gameTypes = gameListModel->getGameTypes().value(gameInfo.room_id());
|
const QMap<int, QString> &gameTypes = gameListModel->getGameTypes().value(gameInfo.room_id());
|
||||||
|
|
||||||
DlgCreateGame dlg(gameInfo, gameTypes, this);
|
auto *dlg = new DlgCreateGame(gameInfo, gameTypes, this);
|
||||||
dlg.exec();
|
dlg->setAttribute(Qt::WA_DeleteOnClose);
|
||||||
|
dlg->exec();
|
||||||
});
|
});
|
||||||
|
|
||||||
QAction copyLink(tr("Cop&y game link"));
|
QAction copyLink(tr("Cop&y game link"));
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
|
||||||
aDemoteFromDeveloper = new QAction(QString(), this);
|
aDemoteFromDeveloper = new QAction(QString(), this);
|
||||||
aGetAdminNotes = new QAction(QString(), this);
|
aGetAdminNotes = new QAction(QString(), this);
|
||||||
aInvestigateUser = new QAction(QString(), this);
|
aInvestigateUser = new QAction(QString(), this);
|
||||||
|
aInvestigateAlts = new QAction(QString(), this);
|
||||||
|
|
||||||
retranslateUi();
|
retranslateUi();
|
||||||
}
|
}
|
||||||
|
|
@ -111,6 +112,7 @@ void UserContextMenu::retranslateUi()
|
||||||
aDemoteFromDeveloper->setText(tr("Demote user from de&veloper"));
|
aDemoteFromDeveloper->setText(tr("Demote user from de&veloper"));
|
||||||
aGetAdminNotes->setText(tr("View admin notes"));
|
aGetAdminNotes->setText(tr("View admin notes"));
|
||||||
aInvestigateUser->setText(tr("Investigate user"));
|
aInvestigateUser->setText(tr("Investigate user"));
|
||||||
|
aInvestigateAlts->setText(tr("Investigate &alts"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer)
|
void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer)
|
||||||
|
|
@ -454,6 +456,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
||||||
menu->addSeparator();
|
menu->addSeparator();
|
||||||
menu->addAction(aGetAdminNotes);
|
menu->addAction(aGetAdminNotes);
|
||||||
menu->addAction(aInvestigateUser);
|
menu->addAction(aInvestigateUser);
|
||||||
|
menu->addAction(aInvestigateAlts);
|
||||||
|
|
||||||
menu->addSeparator();
|
menu->addSeparator();
|
||||||
if (userLevel.testFlag(ServerInfo_User::IsModerator) &&
|
if (userLevel.testFlag(ServerInfo_User::IsModerator) &&
|
||||||
|
|
@ -499,6 +502,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
||||||
aBanHistory->setEnabled(anotherUser);
|
aBanHistory->setEnabled(anotherUser);
|
||||||
aGetAdminNotes->setEnabled(anotherUser);
|
aGetAdminNotes->setEnabled(anotherUser);
|
||||||
aInvestigateUser->setEnabled(anotherUser);
|
aInvestigateUser->setEnabled(anotherUser);
|
||||||
|
aInvestigateAlts->setEnabled(anotherUser);
|
||||||
aPromoteToMod->setEnabled(anotherUser);
|
aPromoteToMod->setEnabled(anotherUser);
|
||||||
aDemoteFromMod->setEnabled(anotherUser);
|
aDemoteFromMod->setEnabled(anotherUser);
|
||||||
aPromoteToJudge->setEnabled(anotherUser);
|
aPromoteToJudge->setEnabled(anotherUser);
|
||||||
|
|
@ -553,6 +557,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
||||||
execAdminNotes(userName);
|
execAdminNotes(userName);
|
||||||
} else if (actionClicked == aInvestigateUser) {
|
} else if (actionClicked == aInvestigateUser) {
|
||||||
execInvestigateUser(userName);
|
execInvestigateUser(userName);
|
||||||
|
} else if (actionClicked == aInvestigateAlts) {
|
||||||
|
execInvestigateAlts(userName);
|
||||||
} else if (actionClicked == aCopyToClipBoard) {
|
} else if (actionClicked == aCopyToClipBoard) {
|
||||||
QClipboard *clipboard = QGuiApplication::clipboard();
|
QClipboard *clipboard = QGuiApplication::clipboard();
|
||||||
clipboard->setText(deckHash);
|
clipboard->setText(deckHash);
|
||||||
|
|
@ -745,6 +751,11 @@ void UserContextMenu::execInvestigateUser(const QString &userName)
|
||||||
tabSupervisor->openTabModeration(userName);
|
tabSupervisor->openTabModeration(userName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UserContextMenu::execInvestigateAlts(const QString &userName)
|
||||||
|
{
|
||||||
|
tabSupervisor->openTabAlts(userName);
|
||||||
|
}
|
||||||
|
|
||||||
void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod)
|
void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod)
|
||||||
{
|
{
|
||||||
Command_AdjustMod cmd;
|
Command_AdjustMod cmd;
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ private:
|
||||||
QAction *aGetAdminNotes;
|
QAction *aGetAdminNotes;
|
||||||
std::function<QList<GameInviteOption>()> gameInviteLinkProvider;
|
std::function<QList<GameInviteOption>()> gameInviteLinkProvider;
|
||||||
QAction *aInvestigateUser;
|
QAction *aInvestigateUser;
|
||||||
|
QAction *aInvestigateAlts;
|
||||||
signals:
|
signals:
|
||||||
void openMessageDialog(const QString &userName, bool focus);
|
void openMessageDialog(const QString &userName, bool focus);
|
||||||
private slots:
|
private slots:
|
||||||
|
|
@ -127,6 +128,7 @@ public:
|
||||||
void execWarnHistory(const QString &userName);
|
void execWarnHistory(const QString &userName);
|
||||||
void execAdminNotes(const QString &userName);
|
void execAdminNotes(const QString &userName);
|
||||||
void execInvestigateUser(const QString &userName);
|
void execInvestigateUser(const QString &userName);
|
||||||
|
void execInvestigateAlts(const QString &userName);
|
||||||
void execAdjustMod(const QString &userName, bool shouldBeMod);
|
void execAdjustMod(const QString &userName, bool shouldBeMod);
|
||||||
void execAdjustJudge(const QString &userName, bool shouldBeJudge);
|
void execAdjustJudge(const QString &userName, bool shouldBeJudge);
|
||||||
void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper);
|
void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper);
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,7 @@ AppearanceSettingsPage::AppearanceSettingsPage()
|
||||||
});
|
});
|
||||||
|
|
||||||
homeTabBackgroundShuffleFrequencySpinBox.setRange(0, 3600);
|
homeTabBackgroundShuffleFrequencySpinBox.setRange(0, 3600);
|
||||||
|
//: Unit following a number of seconds. Keep the leading space unless your language writes number and unit flush.
|
||||||
homeTabBackgroundShuffleFrequencySpinBox.setSuffix(tr(" seconds"));
|
homeTabBackgroundShuffleFrequencySpinBox.setSuffix(tr(" seconds"));
|
||||||
homeTabBackgroundShuffleFrequencySpinBox.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency());
|
homeTabBackgroundShuffleFrequencySpinBox.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency());
|
||||||
connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload<int>(&QSpinBox::valueChanged), &settings.appearance(),
|
connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload<int>(&QSpinBox::valueChanged), &settings.appearance(),
|
||||||
|
|
@ -675,7 +676,7 @@ void AppearanceSettingsPage::retranslateUi()
|
||||||
}
|
}
|
||||||
|
|
||||||
handGroupBox->setTitle(tr("Hand layout"));
|
handGroupBox->setTitle(tr("Hand layout"));
|
||||||
horizontalHandCheckBox.setText(tr("Display hand horizontally (wastes space)"));
|
horizontalHandCheckBox.setText(tr("Display hand horizontally (requires more space)"));
|
||||||
leftJustifiedHandCheckBox.setText(tr("Enable left justification"));
|
leftJustifiedHandCheckBox.setText(tr("Enable left justification"));
|
||||||
|
|
||||||
tableGroupBox->setTitle(tr("Table grid layout"));
|
tableGroupBox->setTitle(tr("Table grid layout"));
|
||||||
|
|
|
||||||
|
|
@ -88,18 +88,21 @@ MessagesSettingsPage::MessagesSettingsPage()
|
||||||
|
|
||||||
auto *chatGrid = new QGridLayout;
|
auto *chatGrid = new QGridLayout;
|
||||||
chatGrid->addWidget(&chatMentionCheckBox, 0, 0);
|
chatGrid->addWidget(&chatMentionCheckBox, 0, 0);
|
||||||
chatGrid->addWidget(&invertMentionForeground, 0, 1);
|
chatGrid->addWidget(&mentionColorStringLabel, 0, 1, Qt::AlignRight);
|
||||||
chatGrid->addWidget(mentionColor, 0, 2);
|
chatGrid->addWidget(mentionColor, 0, 2);
|
||||||
chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0);
|
chatGrid->addWidget(&invertMentionForeground, 0, 3);
|
||||||
chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0);
|
chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0, 1, -1);
|
||||||
chatGrid->addWidget(&hexLabel, 1, 2);
|
chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0, 1, -1);
|
||||||
chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0);
|
chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0, 1, -1);
|
||||||
chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0);
|
chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0, 1, -1);
|
||||||
chatGrid->addWidget(&messagePopups, 5, 0);
|
chatGrid->addWidget(&messagePopups, 5, 0, 1, -1);
|
||||||
chatGrid->addWidget(&mentionPopups, 6, 0);
|
chatGrid->addWidget(&mentionPopups, 6, 0, 1, -1);
|
||||||
chatGrid->addWidget(&roomHistory, 7, 0);
|
chatGrid->addWidget(&roomHistory, 7, 0, 1, -1);
|
||||||
chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0);
|
chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0, 1, -1);
|
||||||
chatGrid->addWidget(&useGameTimeCheckBox, 9, 0);
|
chatGrid->addWidget(&useGameTimeCheckBox, 9, 0, 1, -1);
|
||||||
|
// Expand the first column more than the following ones
|
||||||
|
chatGrid->setColumnStretch(0, 3);
|
||||||
|
chatGrid->setColumnStretch(1, 1);
|
||||||
chatGroupBox = new QGroupBox;
|
chatGroupBox = new QGroupBox;
|
||||||
chatGroupBox->setLayout(chatGrid);
|
chatGroupBox->setLayout(chatGrid);
|
||||||
|
|
||||||
|
|
@ -109,11 +112,14 @@ MessagesSettingsPage::MessagesSettingsPage()
|
||||||
connect(highlightColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateHighlightColor);
|
connect(highlightColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateHighlightColor);
|
||||||
|
|
||||||
auto *highlightNotice = new QGridLayout;
|
auto *highlightNotice = new QGridLayout;
|
||||||
highlightNotice->addWidget(highlightColor, 0, 2);
|
|
||||||
highlightNotice->addWidget(&invertHighlightForeground, 0, 1);
|
|
||||||
highlightNotice->addWidget(&hexHighlightLabel, 1, 2);
|
|
||||||
highlightNotice->addWidget(customAlertString, 0, 0);
|
highlightNotice->addWidget(customAlertString, 0, 0);
|
||||||
highlightNotice->addWidget(&customAlertStringLabel, 1, 0);
|
highlightNotice->addWidget(&highlightColorStringLabel, 0, 1, Qt::AlignRight);
|
||||||
|
highlightNotice->addWidget(highlightColor, 0, 2);
|
||||||
|
highlightNotice->addWidget(&invertHighlightForeground, 0, 3);
|
||||||
|
highlightNotice->addWidget(&customAlertStringLabel, 1, 0, 1, -1);
|
||||||
|
// Expand the first column more than the following ones
|
||||||
|
highlightNotice->setColumnStretch(0, 3);
|
||||||
|
highlightNotice->setColumnStretch(1, 1);
|
||||||
customAlertStringLabel.setBuddy(customAlertString);
|
customAlertStringLabel.setBuddy(customAlertString);
|
||||||
highlightGroupBox = new QGroupBox;
|
highlightGroupBox = new QGroupBox;
|
||||||
highlightGroupBox->setLayout(highlightNotice);
|
highlightGroupBox->setLayout(highlightNotice);
|
||||||
|
|
@ -131,10 +137,8 @@ MessagesSettingsPage::MessagesSettingsPage()
|
||||||
auto *channelGrid = new QGridLayout;
|
auto *channelGrid = new QGridLayout;
|
||||||
channelGrid->addWidget(&channelUnreadColorLabel, 0, 0);
|
channelGrid->addWidget(&channelUnreadColorLabel, 0, 0);
|
||||||
channelGrid->addWidget(channelUnreadColor, 0, 1);
|
channelGrid->addWidget(channelUnreadColor, 0, 1);
|
||||||
channelGrid->addWidget(&hexChannelUnreadLabel, 0, 2);
|
|
||||||
channelGrid->addWidget(&channelMentionColorLabel, 1, 0);
|
channelGrid->addWidget(&channelMentionColorLabel, 1, 0);
|
||||||
channelGrid->addWidget(channelMentionColor, 1, 1);
|
channelGrid->addWidget(channelMentionColor, 1, 1);
|
||||||
channelGrid->addWidget(&hexChannelMentionLabel, 1, 2);
|
|
||||||
channelGroupBox = new QGroupBox;
|
channelGroupBox = new QGroupBox;
|
||||||
channelGroupBox->setLayout(channelGrid);
|
channelGroupBox->setLayout(channelGrid);
|
||||||
|
|
||||||
|
|
@ -331,6 +335,8 @@ void MessagesSettingsPage::retranslateUi()
|
||||||
channelGroupBox->setTitle(tr("Room channel tabs"));
|
channelGroupBox->setTitle(tr("Room channel tabs"));
|
||||||
channelUnreadColorLabel.setText(tr("Unread messages color"));
|
channelUnreadColorLabel.setText(tr("Unread messages color"));
|
||||||
channelMentionColorLabel.setText(tr("Mentioned messages color"));
|
channelMentionColorLabel.setText(tr("Mentioned messages color"));
|
||||||
|
channelUnreadColor->setPlaceholderText(tr("Enter HEX code"));
|
||||||
|
channelMentionColor->setPlaceholderText(tr("Enter HEX code"));
|
||||||
chatMentionCheckBox.setText(tr("Enable chat mentions"));
|
chatMentionCheckBox.setText(tr("Enable chat mentions"));
|
||||||
chatMentionCompleterCheckbox.setText(tr("Enable mention completer"));
|
chatMentionCompleterCheckbox.setText(tr("Enable mention completer"));
|
||||||
messageGroupBox->setTitle(tr("In-game message macros"));
|
messageGroupBox->setTitle(tr("In-game message macros"));
|
||||||
|
|
@ -341,15 +347,15 @@ void MessagesSettingsPage::retranslateUi()
|
||||||
ignoreNonBuddyUserMessages.setText(tr("Ignore private messages sent by non-buddy users"));
|
ignoreNonBuddyUserMessages.setText(tr("Ignore private messages sent by non-buddy users"));
|
||||||
invertMentionForeground.setText(tr("Invert text color"));
|
invertMentionForeground.setText(tr("Invert text color"));
|
||||||
invertHighlightForeground.setText(tr("Invert text color"));
|
invertHighlightForeground.setText(tr("Invert text color"));
|
||||||
|
mentionColorStringLabel.setText(tr("Color"));
|
||||||
|
highlightColorStringLabel.setText(tr("Color"));
|
||||||
|
mentionColor->setPlaceholderText(tr("Enter HEX code"));
|
||||||
|
highlightColor->setPlaceholderText(tr("Enter HEX code"));
|
||||||
messagePopups.setText(tr("Enable desktop notifications for private messages"));
|
messagePopups.setText(tr("Enable desktop notifications for private messages"));
|
||||||
mentionPopups.setText(tr("Enable desktop notification for mentions"));
|
mentionPopups.setText(tr("Enable desktop notification for mentions"));
|
||||||
roomHistory.setText(tr("Enable room message history on join"));
|
roomHistory.setText(tr("Enable room message history on join"));
|
||||||
ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages"));
|
ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages"));
|
||||||
useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs"));
|
useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs"));
|
||||||
hexLabel.setText(tr("(Color is hexadecimal)"));
|
|
||||||
hexHighlightLabel.setText(tr("(Color is hexadecimal)"));
|
|
||||||
hexChannelUnreadLabel.setText(tr("(Color is hexadecimal)"));
|
|
||||||
hexChannelMentionLabel.setText(tr("(Color is hexadecimal)"));
|
|
||||||
customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only"));
|
customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only"));
|
||||||
customAlertString->setPlaceholderText(tr("Word1 Word2 Word3"));
|
customAlertString->setPlaceholderText(tr("Word1 Word2 Word3"));
|
||||||
aAdd->setText(tr("Add New Message"));
|
aAdd->setText(tr("Add New Message"));
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,11 @@ private:
|
||||||
QLineEdit *channelUnreadColor;
|
QLineEdit *channelUnreadColor;
|
||||||
QLineEdit *channelMentionColor;
|
QLineEdit *channelMentionColor;
|
||||||
QLineEdit *customAlertString;
|
QLineEdit *customAlertString;
|
||||||
QLabel hexLabel;
|
|
||||||
QLabel hexHighlightLabel;
|
|
||||||
QLabel channelUnreadColorLabel;
|
QLabel channelUnreadColorLabel;
|
||||||
QLabel channelMentionColorLabel;
|
QLabel channelMentionColorLabel;
|
||||||
QLabel hexChannelUnreadLabel;
|
|
||||||
QLabel hexChannelMentionLabel;
|
|
||||||
QLabel customAlertStringLabel;
|
QLabel customAlertStringLabel;
|
||||||
|
QLabel mentionColorStringLabel;
|
||||||
|
QLabel highlightColorStringLabel;
|
||||||
QLabel explainMessagesLabel;
|
QLabel explainMessagesLabel;
|
||||||
|
|
||||||
void storeSettings();
|
void storeSettings();
|
||||||
|
|
|
||||||
109
cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
#include "alt_table_delegate.h"
|
||||||
|
|
||||||
|
#include "../utility/pill_painter.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QModelIndex>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QStyle>
|
||||||
|
#include <QStyleOptionViewItem>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr int ICON_TEXT_GAP = 4;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
AltTableDelegate::AltTableDelegate(int viaColumn,
|
||||||
|
int warnsColumn,
|
||||||
|
int bansColumn,
|
||||||
|
int sharedColumn,
|
||||||
|
int activeColumn,
|
||||||
|
QObject *parent)
|
||||||
|
: QStyledItemDelegate(parent), viaColumn(viaColumn), warnsColumn(warnsColumn), bansColumn(bansColumn),
|
||||||
|
sharedColumn(sharedColumn), activeColumn(activeColumn)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
|
||||||
|
{
|
||||||
|
const bool selected = (option.state & QStyle::State_Selected) != 0;
|
||||||
|
|
||||||
|
QStyleOptionViewItem opt(option);
|
||||||
|
initStyleOption(&opt, index);
|
||||||
|
painter->setFont(opt.font);
|
||||||
|
QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style();
|
||||||
|
const QString text = index.data(Qt::DisplayRole).toString();
|
||||||
|
const QPalette::ColorRole emphasisRole = selected ? QPalette::HighlightedText : QPalette::WindowText;
|
||||||
|
|
||||||
|
if (index.column() == viaColumn) {
|
||||||
|
bool sharedOk = false;
|
||||||
|
const unsigned shared = index.sibling(index.row(), sharedColumn).data(Qt::DisplayRole).toUInt(&sharedOk);
|
||||||
|
const bool ipMatch = text.startsWith(QStringLiteral("ip:"));
|
||||||
|
// shared_count for an IP match already includes the investigated account,
|
||||||
|
// so anything above 2 means the address is shared beyond that pair.
|
||||||
|
const bool dimmed = sharedOk && ipMatch && shared > 2;
|
||||||
|
const QPalette::ColorRole textRole = (dimmed && !selected) ? QPalette::PlaceholderText : emphasisRole;
|
||||||
|
|
||||||
|
QPalette::ColorRole tintRole = QPalette::NoRole;
|
||||||
|
if (text == QStringLiteral("email")) {
|
||||||
|
tintRole = QPalette::Link;
|
||||||
|
} else if (text.startsWith(QStringLiteral("clientid:"))) {
|
||||||
|
tintRole = QPalette::Highlight;
|
||||||
|
} else if (ipMatch) {
|
||||||
|
tintRole = QPalette::PlaceholderText;
|
||||||
|
}
|
||||||
|
if (tintRole != QPalette::NoRole) {
|
||||||
|
if (!pill_painter::paint(painter, opt, tintRole, textRole, text)) {
|
||||||
|
QStyledItemDelegate::paint(painter, opt, index);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QStyledItemDelegate::paint(painter, opt, index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index.column() == warnsColumn || index.column() == bansColumn) {
|
||||||
|
bool countOk = false;
|
||||||
|
const int count = text.toInt(&countOk);
|
||||||
|
if (countOk && count > 0 && !selected) {
|
||||||
|
const QIcon icon = style->standardIcon(index.column() == bansColumn ? QStyle::SP_MessageBoxCritical
|
||||||
|
: QStyle::SP_MessageBoxWarning);
|
||||||
|
const int iconSize = qMax(0, opt.rect.height() - 2 * ICON_TEXT_GAP);
|
||||||
|
QRect textRect = opt.rect;
|
||||||
|
if (!icon.isNull() && iconSize > 0) {
|
||||||
|
const QRect iconRect(opt.rect.left() + ICON_TEXT_GAP, opt.rect.top() + ICON_TEXT_GAP, iconSize,
|
||||||
|
iconSize);
|
||||||
|
icon.paint(painter, iconRect);
|
||||||
|
textRect.setLeft(iconRect.right() + ICON_TEXT_GAP);
|
||||||
|
}
|
||||||
|
const QString elided = opt.fontMetrics.elidedText(text, opt.textElideMode, textRect.width());
|
||||||
|
style->drawItemText(painter, textRect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, elided,
|
||||||
|
emphasisRole);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index.column() == activeColumn) {
|
||||||
|
const bool active = index.data(Qt::UserRole).toBool();
|
||||||
|
const QPalette::ColorRole tintRole = active ? QPalette::Highlight : QPalette::PlaceholderText;
|
||||||
|
if (!pill_painter::paint(painter, opt, tintRole, emphasisRole, text)) {
|
||||||
|
QStyledItemDelegate::paint(painter, opt, index);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStyledItemDelegate::paint(painter, opt, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
|
||||||
|
{
|
||||||
|
const QSize hint = QStyledItemDelegate::sizeHint(option, index);
|
||||||
|
if (index.column() == viaColumn || index.column() == activeColumn) {
|
||||||
|
return hint + QSize(pill_painter::PAD_X + 2 * ICON_TEXT_GAP, pill_painter::PAD_Y);
|
||||||
|
}
|
||||||
|
if (index.column() == warnsColumn || index.column() == bansColumn) {
|
||||||
|
return hint + QSize(2 * ICON_TEXT_GAP, 0);
|
||||||
|
}
|
||||||
|
return hint;
|
||||||
|
}
|
||||||
37
cockatrice/src/interface/widgets/tabs/alt_table_delegate.h
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
#ifndef ALT_TABLE_DELEGATE_H
|
||||||
|
#define ALT_TABLE_DELEGATE_H
|
||||||
|
|
||||||
|
#include <QStyledItemDelegate>
|
||||||
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge + icon painting for the User Alts table. The match-evidence column
|
||||||
|
* renders as a palette-tinted pill per match type (email / client id / IP), and
|
||||||
|
* an IP shared by more than one other account is dimmed. Warn/ban counts gain
|
||||||
|
* the style's warning/critical icons when nonzero. The active column renders as
|
||||||
|
* a palette-tinted pill (Highlight when active, PlaceholderText when inactive,
|
||||||
|
* from Qt::UserRole). Selected rows and unknown content fall back to default
|
||||||
|
* painting.
|
||||||
|
*/
|
||||||
|
class AltTableDelegate : public QStyledItemDelegate
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit AltTableDelegate(int viaColumn,
|
||||||
|
int warnsColumn,
|
||||||
|
int bansColumn,
|
||||||
|
int sharedColumn,
|
||||||
|
int activeColumn,
|
||||||
|
QObject *parent = nullptr);
|
||||||
|
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
[[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
int viaColumn;
|
||||||
|
int warnsColumn;
|
||||||
|
int bansColumn;
|
||||||
|
int sharedColumn;
|
||||||
|
int activeColumn;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // ALT_TABLE_DELEGATE_H
|
||||||
|
|
@ -38,7 +38,8 @@ public:
|
||||||
[[nodiscard]] QString getTabText() const override
|
[[nodiscard]] QString getTabText() const override
|
||||||
{
|
{
|
||||||
auto cardName = cardToQuery.isNull() ? QString() : cardToQuery->getName();
|
auto cardName = cardToQuery.isNull() ? QString() : cardToQuery->getName();
|
||||||
return tr("EDHRec: ") + cardName;
|
//: EDHRec is a website name, keep it as written. %1 is the card being looked up.
|
||||||
|
return tr("EDHRec: %1").arg(cardName);
|
||||||
}
|
}
|
||||||
|
|
||||||
CardSizeWidget *getCardSizeSlider() const
|
CardSizeWidget *getCardSizeSlider() const
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
#include "report_status_delegate.h"
|
||||||
|
|
||||||
|
#include "../utility/pill_painter.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QStyle>
|
||||||
|
#include <QStyleOptionViewItem>
|
||||||
|
|
||||||
|
ReportStatusDelegate::ReportStatusDelegate(QObject *parent) : QStyledItemDelegate(parent)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
|
||||||
|
{
|
||||||
|
const bool selected = (option.state & QStyle::State_Selected) != 0;
|
||||||
|
QString status = index.data(Qt::UserRole).toString();
|
||||||
|
if (status.isEmpty()) {
|
||||||
|
status = index.data(Qt::DisplayRole).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QPalette::ColorRole tintRole = QPalette::NoRole;
|
||||||
|
if (status == QStringLiteral("open")) {
|
||||||
|
tintRole = QPalette::Highlight;
|
||||||
|
} else if (status == QStringLiteral("assigned")) {
|
||||||
|
tintRole = QPalette::Link;
|
||||||
|
} else if (status == QStringLiteral("resolved") || status == QStringLiteral("dismissed")) {
|
||||||
|
tintRole = QPalette::PlaceholderText;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStyleOptionViewItem opt(option);
|
||||||
|
initStyleOption(&opt, index);
|
||||||
|
painter->setFont(opt.font);
|
||||||
|
|
||||||
|
if (tintRole == QPalette::NoRole) {
|
||||||
|
const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::PlaceholderText;
|
||||||
|
QStyle *fallbackStyle = opt.widget != nullptr ? opt.widget->style() : QApplication::style();
|
||||||
|
fallbackStyle->drawItemText(painter, opt.rect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, opt.text,
|
||||||
|
textRole);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::WindowText;
|
||||||
|
if (!pill_painter::paint(painter, opt, tintRole, textRole, opt.text.isEmpty() ? status : opt.text)) {
|
||||||
|
QStyledItemDelegate::paint(painter, opt, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize ReportStatusDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
|
||||||
|
{
|
||||||
|
const QSize hint = QStyledItemDelegate::sizeHint(option, index);
|
||||||
|
return hint + QSize(pill_painter::PAD_X, pill_painter::PAD_Y);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
#ifndef REPORT_STATUS_DELEGATE_H
|
||||||
|
#define REPORT_STATUS_DELEGATE_H
|
||||||
|
|
||||||
|
#include <QStyledItemDelegate>
|
||||||
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status pill for the report queue table. Paints the status text on a
|
||||||
|
* translucent badge tinted with a palette role per status (open, assigned,
|
||||||
|
* resolved, dismissed), so the colors always follow the active theme.
|
||||||
|
* Falls back to default painting for selected rows and unknown statuses.
|
||||||
|
*/
|
||||||
|
class ReportStatusDelegate : public QStyledItemDelegate
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit ReportStatusDelegate(QObject *parent = nullptr);
|
||||||
|
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
[[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // REPORT_STATUS_DELEGATE_H
|
||||||
699
cockatrice/src/interface/widgets/tabs/tab_alts.cpp
Normal file
|
|
@ -0,0 +1,699 @@
|
||||||
|
#include "tab_alts.h"
|
||||||
|
|
||||||
|
#include "../utility/epoch_format.h"
|
||||||
|
#include "../utility/table_activation.h"
|
||||||
|
#include "abstract_client.h"
|
||||||
|
#include "alt_table_delegate.h"
|
||||||
|
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||||
|
#include "libcockatrice/protocol/pb/response_user_alts.pb.h"
|
||||||
|
|
||||||
|
#include <QAbstractItemView>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QGroupBox>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QHeaderView>
|
||||||
|
#include <QItemSelectionModel>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QList>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QSignalBlocker>
|
||||||
|
#include <QSpinBox>
|
||||||
|
#include <QSplitter>
|
||||||
|
#include <QTableWidget>
|
||||||
|
#include <QTableWidgetItem>
|
||||||
|
#include <QTextEdit>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QWidget>
|
||||||
|
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pb/response_ban_history.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pb/response_warn_history.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pb/serverinfo_ban.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pb/serverinfo_warning.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pending_command.h>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr int COL_ALTS_USER = 0;
|
||||||
|
constexpr int COL_ALTS_VIA = 1;
|
||||||
|
constexpr int COL_ALTS_DEPTH = 2;
|
||||||
|
constexpr int COL_ALTS_SHARED = 3;
|
||||||
|
constexpr int COL_ALTS_IPS = 4;
|
||||||
|
constexpr int COL_ALTS_EMAIL = 5;
|
||||||
|
constexpr int COL_ALTS_CLIENTID = 6;
|
||||||
|
constexpr int COL_ALTS_REGISTERED = 7;
|
||||||
|
constexpr int COL_ALTS_LAST_LOGIN = 8;
|
||||||
|
constexpr int COL_ALTS_WARNS = 9;
|
||||||
|
constexpr int COL_ALTS_BANS = 10;
|
||||||
|
constexpr int COL_ALTS_ACTIVE = 11;
|
||||||
|
constexpr int COL_ALTS_COUNT = 12;
|
||||||
|
|
||||||
|
constexpr int COL_WARNS_TIME = 0;
|
||||||
|
constexpr int COL_WARNS_MODERATOR = 1;
|
||||||
|
constexpr int COL_WARNS_REASON = 2;
|
||||||
|
constexpr int COL_WARNS_COUNT = 3;
|
||||||
|
|
||||||
|
constexpr int COL_BANS_TIME = 0;
|
||||||
|
constexpr int COL_BANS_MODERATOR = 1;
|
||||||
|
constexpr int COL_BANS_LENGTH = 2;
|
||||||
|
constexpr int COL_BANS_REASON = 3;
|
||||||
|
constexpr int COL_BANS_COUNT = 4;
|
||||||
|
|
||||||
|
constexpr int MAX_ALT_DEPTH = 3;
|
||||||
|
// Starting default only (adjustable 1-100000): shared VPN exits routinely
|
||||||
|
// cover dozens of users, so 10 hides the obvious VPN noise but keeps
|
||||||
|
// small shared networks visible.
|
||||||
|
constexpr int DEFAULT_VPN_THRESHOLD = 10;
|
||||||
|
|
||||||
|
void configureAltTable(QTableWidget *table)
|
||||||
|
{
|
||||||
|
table->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||||
|
table->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||||
|
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||||
|
table->verticalHeader()->setVisible(false);
|
||||||
|
table->setAlternatingRowColors(true);
|
||||||
|
table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
|
||||||
|
table->horizontalHeader()->setStretchLastSection(true);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser)
|
||||||
|
: Tab(_tabSupervisor), client(_client)
|
||||||
|
{
|
||||||
|
auto *centralWidget = new QWidget(this);
|
||||||
|
setCentralWidget(centralWidget);
|
||||||
|
|
||||||
|
searchEdit = new QLineEdit;
|
||||||
|
searchEdit->setClearButtonEnabled(true);
|
||||||
|
connect(searchEdit, &QLineEdit::returnPressed, this, &TabAlts::investigateUser);
|
||||||
|
|
||||||
|
depthSpin = new QSpinBox;
|
||||||
|
depthSpin->setMinimum(1);
|
||||||
|
depthSpin->setMaximum(MAX_ALT_DEPTH);
|
||||||
|
depthSpin->setValue(1);
|
||||||
|
depthSpin->setKeyboardTracking(false);
|
||||||
|
connect(depthSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged);
|
||||||
|
|
||||||
|
investigateButton = new QPushButton;
|
||||||
|
connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser);
|
||||||
|
|
||||||
|
backButton = new QPushButton;
|
||||||
|
backButton->setEnabled(false);
|
||||||
|
connect(backButton, &QPushButton::clicked, this, &TabAlts::goBack);
|
||||||
|
|
||||||
|
statusLabel = new QLabel;
|
||||||
|
trailLabel = new QLabel;
|
||||||
|
trailLabel->setTextFormat(Qt::RichText);
|
||||||
|
trailLabel->setTextInteractionFlags(Qt::TextBrowserInteraction);
|
||||||
|
trailLabel->setOpenExternalLinks(false);
|
||||||
|
// The trail grows with every hop, so let it wrap instead of forcing the tab
|
||||||
|
// wider than the window.
|
||||||
|
trailLabel->setWordWrap(true);
|
||||||
|
connect(trailLabel, &QLabel::linkActivated, this, [this](const QString &userName) {
|
||||||
|
if (!userName.isEmpty() && userName != currentUser) {
|
||||||
|
goToTrailEntry(userName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
auto *topBar = new QHBoxLayout;
|
||||||
|
topBar->addWidget(backButton);
|
||||||
|
topBar->addWidget(searchEdit);
|
||||||
|
topBar->addWidget(depthSpin);
|
||||||
|
topBar->addWidget(investigateButton);
|
||||||
|
|
||||||
|
auto makeChip = [this](const QString &text) {
|
||||||
|
auto *chip = new QPushButton(text);
|
||||||
|
chip->setCheckable(true);
|
||||||
|
chip->setChecked(true);
|
||||||
|
connect(chip, &QPushButton::toggled, this, &TabAlts::onSearchFilterChanged);
|
||||||
|
return chip;
|
||||||
|
};
|
||||||
|
emailChip = makeChip(QString());
|
||||||
|
clientIdChip = makeChip(QString());
|
||||||
|
ipChip = makeChip(QString());
|
||||||
|
vpnLabel = new QLabel;
|
||||||
|
vpnSpin = new QSpinBox;
|
||||||
|
vpnSpin->setMinimum(1);
|
||||||
|
vpnSpin->setMaximum(100000);
|
||||||
|
vpnSpin->setValue(DEFAULT_VPN_THRESHOLD);
|
||||||
|
connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onDisplayFilterChanged);
|
||||||
|
windowLabel = new QLabel;
|
||||||
|
monthsSpin = new QSpinBox;
|
||||||
|
monthsSpin->setMinimum(1);
|
||||||
|
monthsSpin->setMaximum(24);
|
||||||
|
monthsSpin->setValue(6);
|
||||||
|
monthsSpin->setKeyboardTracking(false);
|
||||||
|
connect(monthsSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged);
|
||||||
|
|
||||||
|
auto *filterBar = new QHBoxLayout;
|
||||||
|
filterBar->addWidget(emailChip);
|
||||||
|
filterBar->addWidget(clientIdChip);
|
||||||
|
filterBar->addWidget(ipChip);
|
||||||
|
filterBar->addWidget(vpnLabel);
|
||||||
|
filterBar->addWidget(vpnSpin);
|
||||||
|
filterBar->addWidget(windowLabel);
|
||||||
|
filterBar->addWidget(monthsSpin);
|
||||||
|
filterBar->addStretch();
|
||||||
|
|
||||||
|
altsTable = new QTableWidget(0, COL_ALTS_COUNT);
|
||||||
|
configureAltTable(altsTable);
|
||||||
|
altsDelegate =
|
||||||
|
new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, COL_ALTS_ACTIVE, altsTable);
|
||||||
|
altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate);
|
||||||
|
altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate);
|
||||||
|
altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate);
|
||||||
|
altsTable->setItemDelegateForColumn(COL_ALTS_ACTIVE, altsDelegate);
|
||||||
|
connectTableActivation(altsTable, this, [this](const QModelIndex &index) {
|
||||||
|
if (!index.isValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto *userItem = altsTable->item(index.row(), COL_ALTS_USER);
|
||||||
|
if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) {
|
||||||
|
investigate(userItem->text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
connect(altsTable, &QTableWidget::itemSelectionChanged, this, &TabAlts::onAltSelected);
|
||||||
|
|
||||||
|
detailNameValue = new QLabel;
|
||||||
|
detailCountsValue = new QLabel;
|
||||||
|
detailNotesEdit = new QTextEdit;
|
||||||
|
detailNotesEdit->setReadOnly(true);
|
||||||
|
// Compact cap so the warn/ban tables stay visible without scrolling the tab.
|
||||||
|
detailNotesEdit->setMaximumHeight(60);
|
||||||
|
warnsTable = new QTableWidget(0, COL_WARNS_COUNT);
|
||||||
|
configureAltTable(warnsTable);
|
||||||
|
bansTable = new QTableWidget(0, COL_BANS_COUNT);
|
||||||
|
configureAltTable(bansTable);
|
||||||
|
|
||||||
|
auto *detailGrid = new QGridLayout;
|
||||||
|
detailNameLabel = new QLabel;
|
||||||
|
detailCountsLabel = new QLabel;
|
||||||
|
detailNotesLabel = new QLabel;
|
||||||
|
detailGrid->addWidget(detailNameLabel, 0, 0);
|
||||||
|
detailGrid->addWidget(detailNameValue, 0, 1);
|
||||||
|
detailGrid->addWidget(detailCountsLabel, 0, 2);
|
||||||
|
detailGrid->addWidget(detailCountsValue, 0, 3);
|
||||||
|
detailGrid->addWidget(detailNotesLabel, 1, 0);
|
||||||
|
detailGrid->addWidget(detailNotesEdit, 1, 1, 1, 3);
|
||||||
|
|
||||||
|
detailsGroup = new QGroupBox;
|
||||||
|
auto *detailsLayout = new QVBoxLayout(detailsGroup);
|
||||||
|
detailsLayout->addLayout(detailGrid);
|
||||||
|
detailsLayout->addWidget(warnsTable, 1);
|
||||||
|
detailsLayout->addWidget(bansTable, 1);
|
||||||
|
investigateSelectedButton = new QPushButton;
|
||||||
|
investigateSelectedButton->setEnabled(false);
|
||||||
|
connect(investigateSelectedButton, &QPushButton::clicked, this, &TabAlts::investigateSelected);
|
||||||
|
detailsLayout->addWidget(investigateSelectedButton);
|
||||||
|
|
||||||
|
auto *contentSplitter = new QSplitter(Qt::Horizontal);
|
||||||
|
contentSplitter->addWidget(altsTable);
|
||||||
|
contentSplitter->addWidget(detailsGroup);
|
||||||
|
contentSplitter->setStretchFactor(0, 3);
|
||||||
|
contentSplitter->setStretchFactor(1, 2);
|
||||||
|
detailsGroup->setMinimumWidth(260);
|
||||||
|
|
||||||
|
auto *mainLayout = new QVBoxLayout(centralWidget);
|
||||||
|
mainLayout->addLayout(topBar);
|
||||||
|
mainLayout->addLayout(filterBar);
|
||||||
|
mainLayout->addWidget(statusLabel);
|
||||||
|
mainLayout->addWidget(trailLabel);
|
||||||
|
mainLayout->addWidget(contentSplitter, 1);
|
||||||
|
|
||||||
|
retranslateUi();
|
||||||
|
clearDetails();
|
||||||
|
investigate(initialUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::retranslateUi()
|
||||||
|
{
|
||||||
|
searchEdit->setPlaceholderText(tr("User name"));
|
||||||
|
depthSpin->setPrefix(tr("Depth: "));
|
||||||
|
depthSpin->setToolTip(tr("Search depth: 1 finds direct matches, higher values follow alts of alts."));
|
||||||
|
investigateButton->setText(tr("Investigate"));
|
||||||
|
backButton->setText(tr("Back"));
|
||||||
|
backButton->setToolTip(tr("Back to the previously investigated user"));
|
||||||
|
if (currentUser.isEmpty()) {
|
||||||
|
statusLabel->setText(tr("Enter a user name to investigate."));
|
||||||
|
}
|
||||||
|
|
||||||
|
emailChip->setText(tr("Email"));
|
||||||
|
emailChip->setToolTip(tr("Show or hide email matches"));
|
||||||
|
clientIdChip->setText(tr("Client ID"));
|
||||||
|
clientIdChip->setToolTip(tr("Show or hide client ID matches"));
|
||||||
|
ipChip->setText(tr("IP"));
|
||||||
|
ipChip->setToolTip(tr("Show or hide IP address matches"));
|
||||||
|
vpnLabel->setText(tr("Hide IPs shared by more than"));
|
||||||
|
vpnSpin->setSuffix(tr(" users"));
|
||||||
|
windowLabel->setText(tr("Session window"));
|
||||||
|
monthsSpin->setSuffix(tr(" months"));
|
||||||
|
monthsSpin->setToolTip(tr("How far back session IPs and client IDs are considered."));
|
||||||
|
|
||||||
|
altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("IPs"), tr("eMail"),
|
||||||
|
tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"),
|
||||||
|
tr("Active")});
|
||||||
|
altsTable->setToolTip(tr("Double-click or press Enter on a row to investigate that account"));
|
||||||
|
warnsTable->setHorizontalHeaderLabels({tr("Warning time"), tr("Moderator"), tr("Reason")});
|
||||||
|
bansTable->setHorizontalHeaderLabels({tr("Ban time"), tr("Moderator"), tr("Length"), tr("Reason")});
|
||||||
|
|
||||||
|
detailsGroup->setTitle(tr("Selected user"));
|
||||||
|
detailNameLabel->setText(tr("Name:"));
|
||||||
|
detailCountsLabel->setText(tr("Counts:"));
|
||||||
|
detailNotesLabel->setText(tr("Admin notes:"));
|
||||||
|
investigateSelectedButton->setText(tr("Investigate this user"));
|
||||||
|
investigateSelectedButton->setToolTip(tr("Open the selected account as a new investigation"));
|
||||||
|
|
||||||
|
if (!lastAlts.isEmpty()) {
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString TabAlts::formatEpoch(quint64 ts) const
|
||||||
|
{
|
||||||
|
return formatEpochTimestamp(ts, tr("Unknown"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::investigate(const QString &userName)
|
||||||
|
{
|
||||||
|
if (userName.isEmpty() || userName == currentUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Alt matches are symmetric, so revisiting an earlier account is a normal
|
||||||
|
// double-click. Truncate there instead of pushing it again, so the trail
|
||||||
|
// stays a single path.
|
||||||
|
if (backStack.contains(userName)) {
|
||||||
|
goToTrailEntry(userName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!currentUser.isEmpty()) {
|
||||||
|
backStack << currentUser;
|
||||||
|
backButton->setEnabled(true);
|
||||||
|
}
|
||||||
|
loadUser(userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::goToTrailEntry(const QString &userName)
|
||||||
|
{
|
||||||
|
const int index = backStack.indexOf(userName);
|
||||||
|
if (index < 0) {
|
||||||
|
investigate(userName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Clicking an earlier crumb truncates the history there instead of pushing
|
||||||
|
// another entry, so the trail keeps a single path and Back still works.
|
||||||
|
backStack.erase(backStack.begin() + index, backStack.end());
|
||||||
|
backButton->setEnabled(!backStack.isEmpty());
|
||||||
|
loadUser(userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::goBack()
|
||||||
|
{
|
||||||
|
if (backStack.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QString previousUser = backStack.takeLast();
|
||||||
|
backButton->setEnabled(!backStack.isEmpty());
|
||||||
|
loadUser(previousUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::loadUser(const QString &userName)
|
||||||
|
{
|
||||||
|
currentUser = userName;
|
||||||
|
searchEdit->setText(userName);
|
||||||
|
updateTrail();
|
||||||
|
investigateUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::updateTrail()
|
||||||
|
{
|
||||||
|
QStringList trail = backStack;
|
||||||
|
trail << currentUser;
|
||||||
|
trail.removeAll(QString());
|
||||||
|
QStringList links;
|
||||||
|
for (const QString &name : trail) {
|
||||||
|
const QString safeName = name.toHtmlEscaped();
|
||||||
|
if (name == currentUser) {
|
||||||
|
links << QStringLiteral("<b>") + safeName + QStringLiteral("</b>");
|
||||||
|
} else {
|
||||||
|
links << QStringLiteral("<a href=\"") + safeName + QStringLiteral("\">") + safeName +
|
||||||
|
QStringLiteral("</a>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trailLabel->setText(links.join(QStringLiteral(" › ")));
|
||||||
|
trailLabel->setVisible(!links.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::investigateUser()
|
||||||
|
{
|
||||||
|
const QString userName = searchEdit->text().simplified();
|
||||||
|
if (userName.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (userName != currentUser && !currentUser.isEmpty()) {
|
||||||
|
backStack << currentUser;
|
||||||
|
backButton->setEnabled(true);
|
||||||
|
}
|
||||||
|
currentUser = userName;
|
||||||
|
updateTrail();
|
||||||
|
reloadCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::reloadCurrentUser()
|
||||||
|
{
|
||||||
|
lastAlts.clear();
|
||||||
|
selectedUser.clear();
|
||||||
|
clearDetails();
|
||||||
|
altsTable->setRowCount(0);
|
||||||
|
statusLabel->setText(tr("Loading alts for %1...").arg(currentUser));
|
||||||
|
requestAlts(currentUser, depthSpin->value());
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::requestAlts(const QString &userName, int depth)
|
||||||
|
{
|
||||||
|
Command_GetUserAlts cmd;
|
||||||
|
cmd.set_user_name(userName.toStdString());
|
||||||
|
cmd.set_depth(static_cast<google::protobuf::uint32>(depth));
|
||||||
|
cmd.set_include_email(emailChip->isChecked());
|
||||||
|
cmd.set_include_clientid(clientIdChip->isChecked());
|
||||||
|
cmd.set_include_ip(ipChip->isChecked());
|
||||||
|
cmd.set_session_months(static_cast<google::protobuf::uint32>(monthsSpin->value()));
|
||||||
|
PendingCommand *pend = client->prepareModeratorCommand(cmd);
|
||||||
|
connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) {
|
||||||
|
if (userName == currentUser) {
|
||||||
|
altsResponse(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
client->sendCommand(pend);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::altsResponse(const Response &response)
|
||||||
|
{
|
||||||
|
lastAlts.clear();
|
||||||
|
if (response.response_code() != Response::RespOk) {
|
||||||
|
statusLabel->setText(tr("Could not load alts for %1.").arg(currentUser));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext);
|
||||||
|
// The server answers RespOk with no rows when the user does not exist.
|
||||||
|
if (resp.alts_size() == 0) {
|
||||||
|
statusLabel->setText(tr("User %1 not found.").arg(currentUser));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < resp.alts_size(); ++i) {
|
||||||
|
lastAlts << resp.alts(i);
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const
|
||||||
|
{
|
||||||
|
const QString name = QString::fromStdString(alt.user_name());
|
||||||
|
if (name == currentUser) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!alt.has_match_reason() || alt.match_reason().empty()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const QString reason = QString::fromStdString(alt.match_reason());
|
||||||
|
if (reason == QStringLiteral("email")) {
|
||||||
|
return emailChip->isChecked();
|
||||||
|
}
|
||||||
|
if (reason.startsWith(QStringLiteral("clientid:"))) {
|
||||||
|
return clientIdChip->isChecked();
|
||||||
|
}
|
||||||
|
if (reason.startsWith(QStringLiteral("ip:"))) {
|
||||||
|
return ipChip->isChecked() &&
|
||||||
|
(!alt.has_shared_count() || alt.shared_count() <= static_cast<unsigned>(vpnSpin->value()));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::renderTable()
|
||||||
|
{
|
||||||
|
// Blocked so the programmatic refill neither wipes the details panel
|
||||||
|
// (via the selection-cleared path) nor refetches them on restore.
|
||||||
|
const QSignalBlocker blocker(altsTable);
|
||||||
|
altsTable->setRowCount(0);
|
||||||
|
|
||||||
|
int emailCount = 0;
|
||||||
|
int clientIdCount = 0;
|
||||||
|
int ipCount = 0;
|
||||||
|
for (const ServerInfo_UserAlt &alt : lastAlts) {
|
||||||
|
const QString reason = QString::fromStdString(alt.match_reason());
|
||||||
|
if (reason == QStringLiteral("email")) {
|
||||||
|
++emailCount;
|
||||||
|
} else if (reason.startsWith(QStringLiteral("clientid:"))) {
|
||||||
|
++clientIdCount;
|
||||||
|
} else if (reason.startsWith(QStringLiteral("ip:"))) {
|
||||||
|
++ipCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The server omits unchecked match types, so keep the last count while a
|
||||||
|
// chip is off instead of relabelling it "(0)".
|
||||||
|
if (emailChip->isChecked()) {
|
||||||
|
emailChip->setText(tr("Email (%1)").arg(emailCount));
|
||||||
|
}
|
||||||
|
if (clientIdChip->isChecked()) {
|
||||||
|
clientIdChip->setText(tr("Client ID (%1)").arg(clientIdCount));
|
||||||
|
}
|
||||||
|
if (ipChip->isChecked()) {
|
||||||
|
ipChip->setText(tr("IP (%1)").arg(ipCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
QFont userFont = altsTable->font();
|
||||||
|
userFont.setBold(true);
|
||||||
|
const QColor dimColor = palette().color(QPalette::Disabled, QPalette::Text);
|
||||||
|
|
||||||
|
int hidden = 0;
|
||||||
|
int row = 0;
|
||||||
|
for (const ServerInfo_UserAlt &alt : lastAlts) {
|
||||||
|
if (!rowVisible(alt)) {
|
||||||
|
++hidden;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
altsTable->insertRow(row);
|
||||||
|
auto *userItem = new QTableWidgetItem(QString::fromStdString(alt.user_name()));
|
||||||
|
userItem->setFont(userFont);
|
||||||
|
altsTable->setItem(row, COL_ALTS_USER, userItem);
|
||||||
|
altsTable->setItem(row, COL_ALTS_VIA,
|
||||||
|
new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty()
|
||||||
|
? QString::fromStdString(alt.match_reason())
|
||||||
|
: QStringLiteral("-")));
|
||||||
|
altsTable->setItem(row, COL_ALTS_DEPTH,
|
||||||
|
new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-")));
|
||||||
|
altsTable->setItem(
|
||||||
|
row, COL_ALTS_SHARED,
|
||||||
|
new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-")));
|
||||||
|
altsTable->setItem(
|
||||||
|
row, COL_ALTS_IPS,
|
||||||
|
new QTableWidgetItem(alt.has_ip_count() ? QString::number(alt.ip_count()) : QStringLiteral("-")));
|
||||||
|
altsTable->setItem(row, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email())));
|
||||||
|
altsTable->setItem(row, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid())));
|
||||||
|
altsTable->setItem(row, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time())));
|
||||||
|
altsTable->setItem(row, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login())));
|
||||||
|
altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count())));
|
||||||
|
altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count())));
|
||||||
|
auto *activeItem = new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"));
|
||||||
|
activeItem->setData(Qt::UserRole, alt.is_active());
|
||||||
|
altsTable->setItem(row, COL_ALTS_ACTIVE, activeItem);
|
||||||
|
// shared_count already includes the investigated account, so anything
|
||||||
|
// above 2 means the address is shared beyond that pair.
|
||||||
|
if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() &&
|
||||||
|
alt.shared_count() > 2) {
|
||||||
|
for (int col = 0; col < COL_ALTS_COUNT; ++col) {
|
||||||
|
if (col == COL_ALTS_VIA) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (auto *item = altsTable->item(row, col)) {
|
||||||
|
item->setForeground(dimColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
++row;
|
||||||
|
}
|
||||||
|
// The server appends the frontier user as a depth-0 seed row; it is the
|
||||||
|
// investigated user, not one of their related accounts.
|
||||||
|
int relatedCount = 0;
|
||||||
|
for (const ServerInfo_UserAlt &alt : lastAlts) {
|
||||||
|
if (!alt.has_depth() || alt.depth() > 0) {
|
||||||
|
++relatedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hidden == 0) {
|
||||||
|
statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser));
|
||||||
|
} else {
|
||||||
|
statusLabel->setText(tr("%1 shown, %2 hidden by filters (%3 total) related to %4.")
|
||||||
|
.arg(row)
|
||||||
|
.arg(hidden)
|
||||||
|
.arg(relatedCount)
|
||||||
|
.arg(currentUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedUser.isEmpty()) {
|
||||||
|
bool restored = false;
|
||||||
|
for (int restoreRow = 0; restoreRow < altsTable->rowCount(); ++restoreRow) {
|
||||||
|
auto *userItem = altsTable->item(restoreRow, COL_ALTS_USER);
|
||||||
|
if (userItem != nullptr && userItem->text() == selectedUser) {
|
||||||
|
altsTable->setCurrentItem(userItem, QItemSelectionModel::Select | QItemSelectionModel::Rows);
|
||||||
|
restored = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!restored) {
|
||||||
|
selectedUser.clear();
|
||||||
|
clearDetails();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::onSearchFilterChanged()
|
||||||
|
{
|
||||||
|
if (currentUser.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A filter change reloads the user being investigated; it must not switch
|
||||||
|
// to a name that was typed into the search box but never submitted.
|
||||||
|
reloadCurrentUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::onDisplayFilterChanged()
|
||||||
|
{
|
||||||
|
if (lastAlts.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::investigateSelected()
|
||||||
|
{
|
||||||
|
if (!selectedUser.isEmpty() && selectedUser != currentUser) {
|
||||||
|
investigate(selectedUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::onAltSelected()
|
||||||
|
{
|
||||||
|
const auto selected = altsTable->selectedItems();
|
||||||
|
if (selected.isEmpty()) {
|
||||||
|
selectedUser.clear();
|
||||||
|
clearDetails();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto *userItem = altsTable->item(selected.first()->row(), COL_ALTS_USER);
|
||||||
|
if (userItem == nullptr || userItem->text().isEmpty() || userItem->text() == selectedUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedUser = userItem->text();
|
||||||
|
requestDetails(selectedUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::requestDetails(const QString &userName)
|
||||||
|
{
|
||||||
|
detailNameValue->setText(userName);
|
||||||
|
detailCountsValue->setText(tr("Loading..."));
|
||||||
|
detailNotesEdit->clear();
|
||||||
|
warnsTable->setRowCount(0);
|
||||||
|
bansTable->setRowCount(0);
|
||||||
|
investigateSelectedButton->setEnabled(userName != currentUser);
|
||||||
|
|
||||||
|
Command_ReportUserInfo infoCmd;
|
||||||
|
infoCmd.set_user_name(userName.toStdString());
|
||||||
|
PendingCommand *infoPend = client->prepareModeratorCommand(infoCmd);
|
||||||
|
connect(infoPend, &PendingCommand::finished, this, [this, userName](const Response &response) {
|
||||||
|
if (userName == selectedUser) {
|
||||||
|
userDetailsResponse(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
client->sendCommand(infoPend);
|
||||||
|
|
||||||
|
Command_GetWarnHistory warnCmd;
|
||||||
|
warnCmd.set_user_name(userName.toStdString());
|
||||||
|
PendingCommand *warnPend = client->prepareModeratorCommand(warnCmd);
|
||||||
|
connect(warnPend, &PendingCommand::finished, this, [this, userName](const Response &response) {
|
||||||
|
if (userName == selectedUser) {
|
||||||
|
warnHistoryResponse(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
client->sendCommand(warnPend);
|
||||||
|
|
||||||
|
Command_GetBanHistory banCmd;
|
||||||
|
banCmd.set_user_name(userName.toStdString());
|
||||||
|
PendingCommand *banPend = client->prepareModeratorCommand(banCmd);
|
||||||
|
connect(banPend, &PendingCommand::finished, this, [this, userName](const Response &response) {
|
||||||
|
if (userName == selectedUser) {
|
||||||
|
banHistoryResponse(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
client->sendCommand(banPend);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::clearDetails()
|
||||||
|
{
|
||||||
|
detailNameValue->clear();
|
||||||
|
detailCountsValue->setText(tr("Select a row to inspect its history."));
|
||||||
|
detailNotesEdit->clear();
|
||||||
|
warnsTable->setRowCount(0);
|
||||||
|
bansTable->setRowCount(0);
|
||||||
|
investigateSelectedButton->setEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::userDetailsResponse(const Response &response)
|
||||||
|
{
|
||||||
|
if (response.response_code() != Response::RespOk) {
|
||||||
|
detailCountsValue->setText(tr("Error loading user info."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext);
|
||||||
|
detailCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3")
|
||||||
|
.arg(resp.total_reports())
|
||||||
|
.arg(resp.total_bans())
|
||||||
|
.arg(resp.total_warns()));
|
||||||
|
|
||||||
|
if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
|
||||||
|
detailNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes()));
|
||||||
|
} else {
|
||||||
|
detailNotesEdit->setPlainText(tr("(none)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::warnHistoryResponse(const Response &response)
|
||||||
|
{
|
||||||
|
warnsTable->setRowCount(0);
|
||||||
|
if (response.response_code() != Response::RespOk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Response_WarnHistory &resp = response.GetExtension(Response_WarnHistory::ext);
|
||||||
|
warnsTable->setRowCount(resp.warn_list_size());
|
||||||
|
for (int i = 0; i < resp.warn_list_size(); ++i) {
|
||||||
|
const ServerInfo_Warning &warn = resp.warn_list(i);
|
||||||
|
warnsTable->setItem(i, COL_WARNS_TIME, new QTableWidgetItem(QString::fromStdString(warn.time_of())));
|
||||||
|
warnsTable->setItem(i, COL_WARNS_MODERATOR, new QTableWidgetItem(QString::fromStdString(warn.admin_name())));
|
||||||
|
warnsTable->setItem(i, COL_WARNS_REASON, new QTableWidgetItem(QString::fromStdString(warn.reason())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabAlts::banHistoryResponse(const Response &response)
|
||||||
|
{
|
||||||
|
bansTable->setRowCount(0);
|
||||||
|
if (response.response_code() != Response::RespOk) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Response_BanHistory &resp = response.GetExtension(Response_BanHistory::ext);
|
||||||
|
bansTable->setRowCount(resp.ban_list_size());
|
||||||
|
for (int i = 0; i < resp.ban_list_size(); ++i) {
|
||||||
|
const ServerInfo_Ban &ban = resp.ban_list(i);
|
||||||
|
bansTable->setItem(i, COL_BANS_TIME, new QTableWidgetItem(QString::fromStdString(ban.ban_time())));
|
||||||
|
bansTable->setItem(i, COL_BANS_MODERATOR, new QTableWidgetItem(QString::fromStdString(ban.admin_name())));
|
||||||
|
bansTable->setItem(i, COL_BANS_LENGTH, new QTableWidgetItem(QString::fromStdString(ban.ban_length())));
|
||||||
|
bansTable->setItem(i, COL_BANS_REASON, new QTableWidgetItem(QString::fromStdString(ban.ban_reason())));
|
||||||
|
}
|
||||||
|
}
|
||||||
102
cockatrice/src/interface/widgets/tabs/tab_alts.h
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
#ifndef TAB_ALTS_H
|
||||||
|
#define TAB_ALTS_H
|
||||||
|
|
||||||
|
#include "tab.h"
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
#include <QtGlobal>
|
||||||
|
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
|
||||||
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
|
class AbstractClient;
|
||||||
|
class AltTableDelegate;
|
||||||
|
class QGroupBox;
|
||||||
|
class QLabel;
|
||||||
|
class QLineEdit;
|
||||||
|
class QPushButton;
|
||||||
|
class QSpinBox;
|
||||||
|
class QTableWidget;
|
||||||
|
class QTextEdit;
|
||||||
|
class Response;
|
||||||
|
class TabSupervisor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff alt-graph investigation tool. Shows accounts related to the
|
||||||
|
* investigated user (shared email, client id, or recent IP address),
|
||||||
|
* including transitive alts-of-alts, with per-row match evidence.
|
||||||
|
* Selecting a row loads that account's warnings, bans, and admin notes.
|
||||||
|
*/
|
||||||
|
class TabAlts : public Tab
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {});
|
||||||
|
void retranslateUi() override;
|
||||||
|
[[nodiscard]] QString getTabText() const override
|
||||||
|
{
|
||||||
|
return tr("User Alts");
|
||||||
|
}
|
||||||
|
void investigate(const QString &userName);
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void investigateUser();
|
||||||
|
void goBack();
|
||||||
|
void investigateSelected();
|
||||||
|
void altsResponse(const Response &response);
|
||||||
|
void onSearchFilterChanged();
|
||||||
|
void onDisplayFilterChanged();
|
||||||
|
void onAltSelected();
|
||||||
|
void userDetailsResponse(const Response &response);
|
||||||
|
void warnHistoryResponse(const Response &response);
|
||||||
|
void banHistoryResponse(const Response &response);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void loadUser(const QString &userName);
|
||||||
|
void goToTrailEntry(const QString &userName);
|
||||||
|
void updateTrail();
|
||||||
|
void reloadCurrentUser();
|
||||||
|
void requestAlts(const QString &userName, int depth);
|
||||||
|
void requestDetails(const QString &userName);
|
||||||
|
void clearDetails();
|
||||||
|
void renderTable();
|
||||||
|
[[nodiscard]] bool rowVisible(const ServerInfo_UserAlt &alt) const;
|
||||||
|
[[nodiscard]] QString formatEpoch(quint64 ts) const;
|
||||||
|
|
||||||
|
AbstractClient *client;
|
||||||
|
QString currentUser;
|
||||||
|
QString selectedUser;
|
||||||
|
QList<QString> backStack;
|
||||||
|
QList<ServerInfo_UserAlt> lastAlts;
|
||||||
|
|
||||||
|
QLineEdit *searchEdit;
|
||||||
|
QSpinBox *depthSpin;
|
||||||
|
QPushButton *investigateButton;
|
||||||
|
QPushButton *backButton;
|
||||||
|
QPushButton *investigateSelectedButton;
|
||||||
|
QLabel *trailLabel;
|
||||||
|
QLabel *statusLabel;
|
||||||
|
QTableWidget *altsTable;
|
||||||
|
AltTableDelegate *altsDelegate;
|
||||||
|
|
||||||
|
QPushButton *emailChip;
|
||||||
|
QPushButton *clientIdChip;
|
||||||
|
QPushButton *ipChip;
|
||||||
|
QLabel *vpnLabel;
|
||||||
|
QSpinBox *vpnSpin;
|
||||||
|
QLabel *windowLabel;
|
||||||
|
QSpinBox *monthsSpin;
|
||||||
|
|
||||||
|
QLabel *detailNameValue;
|
||||||
|
QLabel *detailCountsValue;
|
||||||
|
QTextEdit *detailNotesEdit;
|
||||||
|
QTableWidget *warnsTable;
|
||||||
|
QTableWidget *bansTable;
|
||||||
|
|
||||||
|
QGroupBox *detailsGroup;
|
||||||
|
QLabel *detailNameLabel;
|
||||||
|
QLabel *detailCountsLabel;
|
||||||
|
QLabel *detailNotesLabel;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // TAB_ALTS_H
|
||||||
|
|
@ -330,6 +330,9 @@ void TabGame::emitUserEvent()
|
||||||
|
|
||||||
TabGame::~TabGame()
|
TabGame::~TabGame()
|
||||||
{
|
{
|
||||||
|
if (aCardMenu && aCardMenu->menu() && aCardMenu->menu() != emptyCardMenu) {
|
||||||
|
delete aCardMenu->menu();
|
||||||
|
}
|
||||||
for (auto &player : game->getPlayerManager()->getPlayers()) {
|
for (auto &player : game->getPlayerManager()->getPlayers()) {
|
||||||
player->clear();
|
player->clear();
|
||||||
}
|
}
|
||||||
|
|
@ -570,8 +573,10 @@ void TabGame::adminLockChanged(bool lock)
|
||||||
|
|
||||||
void TabGame::actGameInfo()
|
void TabGame::actGameInfo()
|
||||||
{
|
{
|
||||||
DlgCreateGame dlg(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this);
|
// Heap-allocated so that destroying this tab while the dialog is open cannot delete a stack object.
|
||||||
dlg.exec();
|
auto *dlg = new DlgCreateGame(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this);
|
||||||
|
dlg->setAttribute(Qt::WA_DeleteOnClose);
|
||||||
|
dlg->exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
void TabGame::actCopyGameLink()
|
void TabGame::actCopyGameLink()
|
||||||
|
|
@ -1156,10 +1161,16 @@ QString TabGame::getTabText() const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param menu The menu to set. Pass in nullptr to set the menu to empty.
|
* @param menu The menu to set. Pass in nullptr to set the menu to empty.
|
||||||
|
*
|
||||||
|
* The previous card menu is deleted here: QAction::setMenu() does not take ownership, so whoever installs the menu
|
||||||
|
* (this widget) has to dispose of the one it replaces. The empty placeholder menu is reused instead of deleted.
|
||||||
*/
|
*/
|
||||||
void TabGame::setCardMenu(CardMenu *menu)
|
void TabGame::setCardMenu(CardMenu *menu)
|
||||||
{
|
{
|
||||||
if (!aCardMenu) {
|
if (!aCardMenu || !emptyCardMenu) {
|
||||||
|
if (menu) {
|
||||||
|
menu->deleteLater();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1169,10 +1180,11 @@ void TabGame::setCardMenu(CardMenu *menu)
|
||||||
gameView->viewport()->update();
|
gameView->viewport()->update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (menu) {
|
|
||||||
aCardMenu->setMenu(menu);
|
QMenu *previousMenu = aCardMenu->menu();
|
||||||
} else {
|
aCardMenu->setMenu(menu ? menu : emptyCardMenu);
|
||||||
aCardMenu->setMenu(new QMenu);
|
if (previousMenu && previousMenu != emptyCardMenu && previousMenu != menu) {
|
||||||
|
previousMenu->deleteLater();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1256,7 +1268,8 @@ void TabGame::createMenuItems()
|
||||||
|
|
||||||
gameMenu->addSeparator();
|
gameMenu->addSeparator();
|
||||||
|
|
||||||
aCardMenu = gameMenu->addMenu(new QMenu(this));
|
emptyCardMenu = new QMenu(this);
|
||||||
|
aCardMenu = gameMenu->addMenu(emptyCardMenu);
|
||||||
|
|
||||||
// Sync the new action with the same state the dock button already shows.
|
// Sync the new action with the same state the dock button already shows.
|
||||||
updateInviteButtonState();
|
updateInviteButtonState();
|
||||||
|
|
@ -1288,6 +1301,7 @@ void TabGame::createReplayMenuItems()
|
||||||
gameMenu->addAction(aLeaveGame);
|
gameMenu->addAction(aLeaveGame);
|
||||||
|
|
||||||
aCardMenu = nullptr;
|
aCardMenu = nullptr;
|
||||||
|
emptyCardMenu = nullptr;
|
||||||
|
|
||||||
addTabMenu(gameMenu);
|
addTabMenu(gameMenu);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,8 @@ private:
|
||||||
QAction *aInviteToGame = nullptr;
|
QAction *aInviteToGame = nullptr;
|
||||||
QList<QAction *> phaseActions;
|
QList<QAction *> phaseActions;
|
||||||
QAction *aCardMenu;
|
QAction *aCardMenu;
|
||||||
|
/** @brief Placeholder submenu shown when no card is selected; reused instead of reallocated. */
|
||||||
|
QMenu *emptyCardMenu = nullptr;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief The actions associated with managing a QDockWidget
|
* @brief The actions associated with managing a QDockWidget
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
#include "tab_moderation.h"
|
#include "tab_moderation.h"
|
||||||
|
|
||||||
|
#include "../utility/epoch_format.h"
|
||||||
|
#include "../utility/table_activation.h"
|
||||||
#include "abstract_client.h"
|
#include "abstract_client.h"
|
||||||
#include "libcockatrice/protocol/pb/admin_commands.pb.h"
|
#include "libcockatrice/protocol/pb/admin_commands.pb.h"
|
||||||
#include "libcockatrice/protocol/pb/response.pb.h"
|
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||||
#include "libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h"
|
#include "libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h"
|
||||||
#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h"
|
#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h"
|
||||||
#include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h"
|
#include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h"
|
||||||
|
#include "tab_supervisor.h"
|
||||||
|
|
||||||
#include <QAbstractItemView>
|
#include <QAbstractItemView>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
|
@ -21,6 +24,7 @@
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
#include <QSplitter>
|
#include <QSplitter>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
|
#include <QTabWidget>
|
||||||
#include <QTableWidget>
|
#include <QTableWidget>
|
||||||
#include <QTableWidgetItem>
|
#include <QTableWidgetItem>
|
||||||
#include <QTextEdit>
|
#include <QTextEdit>
|
||||||
|
|
@ -99,38 +103,67 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
|
||||||
topBar->addWidget(removeBannerButton);
|
topBar->addWidget(removeBannerButton);
|
||||||
|
|
||||||
userInfoGroup = new QGroupBox;
|
userInfoGroup = new QGroupBox;
|
||||||
userInfoNameLabel = new QLabel;
|
|
||||||
userInfoNameValue = new QLabel;
|
userInfoNameValue = new QLabel;
|
||||||
userInfoRegisteredLabel = new QLabel;
|
QFont displayFont = userInfoNameValue->font();
|
||||||
userInfoRegisteredValue = new QLabel;
|
displayFont.setPointSize(displayFont.pointSize() + 6);
|
||||||
userInfoLastLoginLabel = new QLabel;
|
displayFont.setBold(true);
|
||||||
userInfoLastLoginValue = new QLabel;
|
userInfoNameValue->setFont(displayFont);
|
||||||
userInfoStatusLabel = new QLabel;
|
|
||||||
userInfoStatusValue = new QLabel;
|
userInfoStatusValue = new QLabel;
|
||||||
userInfoCountsLabel = new QLabel;
|
QFont statusFont = userInfoStatusValue->font();
|
||||||
userInfoCountsValue = new QLabel;
|
statusFont.setBold(true);
|
||||||
|
userInfoStatusValue->setFont(statusFont);
|
||||||
|
sublineLabel = new QLabel;
|
||||||
|
|
||||||
|
auto makeCountCard = [](const QString &title, QLabel *&value) {
|
||||||
|
auto *card = new QGroupBox(title);
|
||||||
|
value = new QLabel(QStringLiteral("–"), card);
|
||||||
|
QFont cardFont = value->font();
|
||||||
|
cardFont.setPointSize(cardFont.pointSize() + 6);
|
||||||
|
cardFont.setBold(true);
|
||||||
|
value->setFont(cardFont);
|
||||||
|
value->setAlignment(Qt::AlignCenter);
|
||||||
|
auto *cardLayout = new QVBoxLayout(card);
|
||||||
|
cardLayout->addWidget(value);
|
||||||
|
return card;
|
||||||
|
};
|
||||||
|
cardReports = makeCountCard(QString(), cardReportsValue);
|
||||||
|
cardWarns = makeCountCard(QString(), cardWarnsValue);
|
||||||
|
cardBans = makeCountCard(QString(), cardBansValue);
|
||||||
|
|
||||||
|
altsCard = new QGroupBox;
|
||||||
|
altsCountValue = new QLabel(QStringLiteral("–"), altsCard);
|
||||||
|
QFont altsFont = altsCountValue->font();
|
||||||
|
altsFont.setPointSize(altsFont.pointSize() + 6);
|
||||||
|
altsFont.setBold(true);
|
||||||
|
altsCountValue->setFont(altsFont);
|
||||||
|
altsCountValue->setAlignment(Qt::AlignCenter);
|
||||||
|
openAltsButton = new QPushButton;
|
||||||
|
connect(openAltsButton, &QPushButton::clicked, this, &TabModeration::openAltsGraph);
|
||||||
|
auto *altsCardLayout = new QVBoxLayout(altsCard);
|
||||||
|
altsCardLayout->addWidget(altsCountValue);
|
||||||
|
altsCardLayout->addWidget(openAltsButton);
|
||||||
|
|
||||||
|
auto *cardsRow = new QHBoxLayout;
|
||||||
|
cardsRow->addWidget(cardReports);
|
||||||
|
cardsRow->addWidget(cardWarns);
|
||||||
|
cardsRow->addWidget(cardBans);
|
||||||
|
cardsRow->addWidget(altsCard);
|
||||||
|
|
||||||
userInfoNotesLabel = new QLabel;
|
userInfoNotesLabel = new QLabel;
|
||||||
userInfoNotesEdit = new QTextEdit;
|
userInfoNotesEdit = new QTextEdit;
|
||||||
userInfoNotesEdit->setReadOnly(true);
|
userInfoNotesEdit->setReadOnly(true);
|
||||||
|
|
||||||
auto *infoGrid = new QGridLayout;
|
auto *identityRow = new QHBoxLayout;
|
||||||
infoGrid->addWidget(userInfoNameLabel, 0, 0);
|
identityRow->addWidget(userInfoNameValue);
|
||||||
infoGrid->addWidget(userInfoNameValue, 0, 1);
|
identityRow->addStretch();
|
||||||
infoGrid->addWidget(userInfoRegisteredLabel, 0, 2);
|
identityRow->addWidget(userInfoStatusValue);
|
||||||
infoGrid->addWidget(userInfoRegisteredValue, 0, 3);
|
|
||||||
infoGrid->addWidget(userInfoLastLoginLabel, 1, 0);
|
|
||||||
infoGrid->addWidget(userInfoLastLoginValue, 1, 1);
|
|
||||||
infoGrid->addWidget(userInfoStatusLabel, 1, 2);
|
|
||||||
infoGrid->addWidget(userInfoStatusValue, 1, 3);
|
|
||||||
infoGrid->addWidget(userInfoCountsLabel, 2, 0);
|
|
||||||
infoGrid->addWidget(userInfoCountsValue, 2, 1, 1, 3);
|
|
||||||
infoGrid->addWidget(userInfoNotesLabel, 3, 0, Qt::AlignTop);
|
|
||||||
infoGrid->addWidget(userInfoNotesEdit, 3, 1, 1, 3);
|
|
||||||
infoGrid->setColumnStretch(1, 1);
|
|
||||||
infoGrid->setColumnStretch(3, 1);
|
|
||||||
|
|
||||||
auto *infoLayout = new QVBoxLayout(userInfoGroup);
|
auto *infoLayout = new QVBoxLayout(userInfoGroup);
|
||||||
infoLayout->addLayout(infoGrid);
|
infoLayout->addLayout(identityRow);
|
||||||
|
infoLayout->addWidget(sublineLabel);
|
||||||
|
infoLayout->addLayout(cardsRow);
|
||||||
|
infoLayout->addWidget(userInfoNotesLabel);
|
||||||
|
infoLayout->addWidget(userInfoNotesEdit);
|
||||||
|
|
||||||
auto configureTable = [](QTableWidget *table) {
|
auto configureTable = [](QTableWidget *table) {
|
||||||
table->setSelectionBehavior(QAbstractItemView::SelectRows);
|
table->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||||
|
|
@ -142,19 +175,27 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
|
||||||
table->horizontalHeader()->setStretchLastSection(true);
|
table->horizontalHeader()->setStretchLastSection(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
altsGroup = new QGroupBox;
|
|
||||||
altsTable = new QTableWidget(0, COL_ALTS_COUNT);
|
altsTable = new QTableWidget(0, COL_ALTS_COUNT);
|
||||||
configureTable(altsTable);
|
configureTable(altsTable);
|
||||||
auto *altsLayout = new QVBoxLayout(altsGroup);
|
connectTableActivation(altsTable, this, [this](const QModelIndex &index) {
|
||||||
|
if (!index.isValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
auto *userItem = altsTable->item(index.row(), COL_ALTS_USER);
|
||||||
|
if (userItem != nullptr && !userItem->text().isEmpty()) {
|
||||||
|
getTabSupervisor()->openTabAlts(userItem->text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
auto *altsPage = new QWidget;
|
||||||
|
auto *altsLayout = new QVBoxLayout(altsPage);
|
||||||
altsLayout->addWidget(altsTable);
|
altsLayout->addWidget(altsTable);
|
||||||
|
|
||||||
sessionsGroup = new QGroupBox;
|
|
||||||
sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT);
|
sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT);
|
||||||
configureTable(sessionsTable);
|
configureTable(sessionsTable);
|
||||||
auto *sessionsLayout = new QVBoxLayout(sessionsGroup);
|
auto *sessionsPage = new QWidget;
|
||||||
|
auto *sessionsLayout = new QVBoxLayout(sessionsPage);
|
||||||
sessionsLayout->addWidget(sessionsTable);
|
sessionsLayout->addWidget(sessionsTable);
|
||||||
|
|
||||||
staffGroup = new QGroupBox;
|
|
||||||
staffTable = new QTableWidget(0, COL_STAFF_COUNT);
|
staffTable = new QTableWidget(0, COL_STAFF_COUNT);
|
||||||
configureTable(staffTable);
|
configureTable(staffTable);
|
||||||
refreshStaffButton = new QPushButton;
|
refreshStaffButton = new QPushButton;
|
||||||
|
|
@ -162,19 +203,21 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
|
||||||
auto *staffHeader = new QHBoxLayout;
|
auto *staffHeader = new QHBoxLayout;
|
||||||
staffHeader->addStretch();
|
staffHeader->addStretch();
|
||||||
staffHeader->addWidget(refreshStaffButton);
|
staffHeader->addWidget(refreshStaffButton);
|
||||||
auto *staffLayout = new QVBoxLayout(staffGroup);
|
auto *staffPage = new QWidget;
|
||||||
|
auto *staffLayout = new QVBoxLayout(staffPage);
|
||||||
staffLayout->addWidget(staffTable);
|
staffLayout->addWidget(staffTable);
|
||||||
staffLayout->addLayout(staffHeader);
|
staffLayout->addLayout(staffHeader);
|
||||||
|
|
||||||
|
sectionTabs = new QTabWidget;
|
||||||
|
sectionTabs->addTab(altsPage, QString());
|
||||||
|
sectionTabs->addTab(sessionsPage, QString());
|
||||||
|
sectionTabs->addTab(staffPage, QString());
|
||||||
|
|
||||||
auto *splitter = new QSplitter(Qt::Vertical);
|
auto *splitter = new QSplitter(Qt::Vertical);
|
||||||
splitter->addWidget(userInfoGroup);
|
splitter->addWidget(userInfoGroup);
|
||||||
splitter->addWidget(altsGroup);
|
splitter->addWidget(sectionTabs);
|
||||||
splitter->addWidget(sessionsGroup);
|
|
||||||
splitter->addWidget(staffGroup);
|
|
||||||
splitter->setStretchFactor(0, 1);
|
splitter->setStretchFactor(0, 1);
|
||||||
splitter->setStretchFactor(1, 2);
|
splitter->setStretchFactor(1, 2);
|
||||||
splitter->setStretchFactor(2, 2);
|
|
||||||
splitter->setStretchFactor(3, 1);
|
|
||||||
|
|
||||||
auto *mainLayout = new QVBoxLayout(centralWidget);
|
auto *mainLayout = new QVBoxLayout(centralWidget);
|
||||||
mainLayout->addLayout(topBar);
|
mainLayout->addLayout(topBar);
|
||||||
|
|
@ -196,45 +239,56 @@ void TabModeration::retranslateUi()
|
||||||
refreshStaffButton->setText(tr("Refresh"));
|
refreshStaffButton->setText(tr("Refresh"));
|
||||||
|
|
||||||
userInfoGroup->setTitle(tr("User Info"));
|
userInfoGroup->setTitle(tr("User Info"));
|
||||||
userInfoNameLabel->setText(tr("Name:"));
|
|
||||||
userInfoRegisteredLabel->setText(tr("Registered:"));
|
|
||||||
userInfoLastLoginLabel->setText(tr("Last login:"));
|
|
||||||
userInfoStatusLabel->setText(tr("Status:"));
|
|
||||||
userInfoCountsLabel->setText(tr("Counts:"));
|
|
||||||
userInfoNotesLabel->setText(tr("Admin notes:"));
|
userInfoNotesLabel->setText(tr("Admin notes:"));
|
||||||
|
cardReports->setTitle(tr("Reports"));
|
||||||
|
cardWarns->setTitle(tr("Warnings"));
|
||||||
|
cardBans->setTitle(tr("Bans"));
|
||||||
|
altsCard->setTitle(tr("Alts"));
|
||||||
|
openAltsButton->setText(tr("Open graph"));
|
||||||
|
openAltsButton->setToolTip(tr("Open this user in the User Alts tab"));
|
||||||
|
|
||||||
altsGroup->setTitle(tr("Alts"));
|
sectionTabs->setTabText(0, tr("Alts"));
|
||||||
sessionsGroup->setTitle(tr("Sessions"));
|
sectionTabs->setTabText(1, tr("Sessions"));
|
||||||
staffGroup->setTitle(tr("Staff Last Logins"));
|
sectionTabs->setTabText(2, tr("Staff Last Logins"));
|
||||||
|
|
||||||
|
if (cachedUserInfoValid) {
|
||||||
|
applyUserInfo();
|
||||||
|
}
|
||||||
|
if (cachedAltsCount >= 0) {
|
||||||
|
applyAltsCount();
|
||||||
|
}
|
||||||
|
|
||||||
altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"),
|
altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"),
|
||||||
tr("Warns"), tr("Bans"), tr("Active")});
|
tr("Warns"), tr("Bans"), tr("Active")});
|
||||||
|
altsTable->setToolTip(tr("Double-click or press Enter on a row to open it in the User Alts tab"));
|
||||||
sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")});
|
sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")});
|
||||||
staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")});
|
staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")});
|
||||||
}
|
}
|
||||||
|
|
||||||
QString TabModeration::formatEpoch(quint64 ts) const
|
QString TabModeration::formatEpoch(quint64 ts) const
|
||||||
{
|
{
|
||||||
if (ts == 0) {
|
return formatEpochTimestamp(ts, tr("Unknown"));
|
||||||
return tr("Unknown");
|
|
||||||
}
|
|
||||||
return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void TabModeration::clearUserData()
|
void TabModeration::clearUserData()
|
||||||
{
|
{
|
||||||
currentUser.clear();
|
currentUser.clear();
|
||||||
|
cachedUserInfoValid = false;
|
||||||
|
cachedAltsCount = -1;
|
||||||
userInfoNameValue->clear();
|
userInfoNameValue->clear();
|
||||||
userInfoRegisteredValue->clear();
|
|
||||||
userInfoLastLoginValue->clear();
|
|
||||||
userInfoStatusValue->clear();
|
userInfoStatusValue->clear();
|
||||||
userInfoCountsValue->clear();
|
sublineLabel->clear();
|
||||||
|
cardReportsValue->setText(QStringLiteral("–"));
|
||||||
|
cardWarnsValue->setText(QStringLiteral("–"));
|
||||||
|
cardBansValue->setText(QStringLiteral("–"));
|
||||||
|
altsCountValue->setText(QStringLiteral("–"));
|
||||||
userInfoNotesEdit->clear();
|
userInfoNotesEdit->clear();
|
||||||
altsTable->setRowCount(0);
|
altsTable->setRowCount(0);
|
||||||
sessionsTable->setRowCount(0);
|
sessionsTable->setRowCount(0);
|
||||||
resetPasswordButton->setEnabled(false);
|
resetPasswordButton->setEnabled(false);
|
||||||
removeAvatarButton->setEnabled(false);
|
removeAvatarButton->setEnabled(false);
|
||||||
removeBannerButton->setEnabled(false);
|
removeBannerButton->setEnabled(false);
|
||||||
|
openAltsButton->setEnabled(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void TabModeration::investigate(const QString &userName)
|
void TabModeration::investigate(const QString &userName)
|
||||||
|
|
@ -253,9 +307,13 @@ void TabModeration::investigateUser()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentUser = userName;
|
currentUser = userName;
|
||||||
|
cachedUserInfoValid = false;
|
||||||
|
cachedAltsCount = -1;
|
||||||
resetPasswordButton->setEnabled(true);
|
resetPasswordButton->setEnabled(true);
|
||||||
removeAvatarButton->setEnabled(true);
|
removeAvatarButton->setEnabled(true);
|
||||||
removeBannerButton->setEnabled(true);
|
removeBannerButton->setEnabled(true);
|
||||||
|
openAltsButton->setEnabled(true);
|
||||||
|
altsCountValue->setText(QStringLiteral("…"));
|
||||||
altsTable->setRowCount(0);
|
altsTable->setRowCount(0);
|
||||||
sessionsTable->setRowCount(0);
|
sessionsTable->setRowCount(0);
|
||||||
requestUserInfo(userName);
|
requestUserInfo(userName);
|
||||||
|
|
@ -263,13 +321,22 @@ void TabModeration::investigateUser()
|
||||||
requestAlts(userName);
|
requestAlts(userName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TabModeration::openAltsGraph()
|
||||||
|
{
|
||||||
|
if (currentUser.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
getTabSupervisor()->openTabAlts(currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
void TabModeration::requestUserInfo(const QString &userName)
|
void TabModeration::requestUserInfo(const QString &userName)
|
||||||
{
|
{
|
||||||
userInfoNameValue->setText(userName);
|
userInfoNameValue->setText(userName);
|
||||||
userInfoRegisteredValue->setText(tr("Loading..."));
|
sublineLabel->setText(tr("Loading..."));
|
||||||
userInfoLastLoginValue->setText(tr("Loading..."));
|
|
||||||
userInfoStatusValue->clear();
|
userInfoStatusValue->clear();
|
||||||
userInfoCountsValue->clear();
|
cardReportsValue->setText(QStringLiteral("…"));
|
||||||
|
cardWarnsValue->setText(QStringLiteral("…"));
|
||||||
|
cardBansValue->setText(QStringLiteral("…"));
|
||||||
userInfoNotesEdit->clear();
|
userInfoNotesEdit->clear();
|
||||||
|
|
||||||
Command_ReportUserInfo cmd;
|
Command_ReportUserInfo cmd;
|
||||||
|
|
@ -282,9 +349,12 @@ void TabModeration::requestUserInfo(const QString &userName)
|
||||||
void TabModeration::userInfoResponse(const Response &response)
|
void TabModeration::userInfoResponse(const Response &response)
|
||||||
{
|
{
|
||||||
if (response.response_code() != Response::RespOk) {
|
if (response.response_code() != Response::RespOk) {
|
||||||
userInfoRegisteredValue->clear();
|
cachedUserInfoValid = false;
|
||||||
userInfoLastLoginValue->clear();
|
sublineLabel->clear();
|
||||||
userInfoStatusValue->setText(tr("Error loading user info."));
|
userInfoStatusValue->setText(tr("Error loading user info."));
|
||||||
|
cardReportsValue->setText(QStringLiteral("–"));
|
||||||
|
cardWarnsValue->setText(QStringLiteral("–"));
|
||||||
|
cardBansValue->setText(QStringLiteral("–"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,20 +363,28 @@ void TabModeration::userInfoResponse(const Response &response)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
userInfoRegisteredValue->setText(formatEpoch(resp.registration_time()));
|
cachedUserInfo.CopyFrom(resp);
|
||||||
userInfoLastLoginValue->setText(formatEpoch(resp.last_login()));
|
cachedUserInfoValid = true;
|
||||||
|
applyUserInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabModeration::applyUserInfo()
|
||||||
|
{
|
||||||
|
const Response_ReportUserInfo &resp = cachedUserInfo;
|
||||||
|
sublineLabel->setText(tr("Registered %1 · Last login %2")
|
||||||
|
.arg(formatEpoch(resp.registration_time()))
|
||||||
|
.arg(formatEpoch(resp.last_login())));
|
||||||
|
|
||||||
QStringList statusParts;
|
QStringList statusParts;
|
||||||
statusParts << (resp.is_active() ? tr("active") : tr("inactive"));
|
statusParts << (resp.is_active() ? tr("active") : tr("inactive"));
|
||||||
if (resp.has_is_admin() && resp.is_admin()) {
|
if (resp.has_is_admin() && resp.is_admin()) {
|
||||||
statusParts << tr("admin");
|
statusParts << tr("admin");
|
||||||
}
|
}
|
||||||
userInfoStatusValue->setText(statusParts.join(", "));
|
userInfoStatusValue->setText(statusParts.join(QStringLiteral(", ")));
|
||||||
|
|
||||||
userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3")
|
cardReportsValue->setText(QString::number(resp.total_reports()));
|
||||||
.arg(resp.total_reports())
|
cardWarnsValue->setText(QString::number(resp.total_warns()));
|
||||||
.arg(resp.total_bans())
|
cardBansValue->setText(QString::number(resp.total_bans()));
|
||||||
.arg(resp.total_warns()));
|
|
||||||
|
|
||||||
if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
|
if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
|
||||||
userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes()));
|
userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes()));
|
||||||
|
|
@ -315,12 +393,21 @@ void TabModeration::userInfoResponse(const Response &response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TabModeration::applyAltsCount()
|
||||||
|
{
|
||||||
|
altsCountValue->setText(tr("%1 related").arg(cachedAltsCount));
|
||||||
|
}
|
||||||
|
|
||||||
void TabModeration::requestSessions(const QString &userName)
|
void TabModeration::requestSessions(const QString &userName)
|
||||||
{
|
{
|
||||||
Command_GetUserSessions cmd;
|
Command_GetUserSessions cmd;
|
||||||
cmd.set_user_name(userName.toStdString());
|
cmd.set_user_name(userName.toStdString());
|
||||||
PendingCommand *pend = client->prepareModeratorCommand(cmd);
|
PendingCommand *pend = client->prepareModeratorCommand(cmd);
|
||||||
connect(pend, &PendingCommand::finished, this, &TabModeration::sessionsResponse);
|
connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) {
|
||||||
|
if (userName == currentUser) {
|
||||||
|
sessionsResponse(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
client->sendCommand(pend);
|
client->sendCommand(pend);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,7 +439,11 @@ void TabModeration::requestAlts(const QString &userName)
|
||||||
Command_GetUserAlts cmd;
|
Command_GetUserAlts cmd;
|
||||||
cmd.set_user_name(userName.toStdString());
|
cmd.set_user_name(userName.toStdString());
|
||||||
PendingCommand *pend = client->prepareModeratorCommand(cmd);
|
PendingCommand *pend = client->prepareModeratorCommand(cmd);
|
||||||
connect(pend, &PendingCommand::finished, this, &TabModeration::altsResponse);
|
connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) {
|
||||||
|
if (userName == currentUser) {
|
||||||
|
altsResponse(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
client->sendCommand(pend);
|
client->sendCommand(pend);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,10 +451,21 @@ void TabModeration::altsResponse(const Response &response)
|
||||||
{
|
{
|
||||||
altsTable->setRowCount(0);
|
altsTable->setRowCount(0);
|
||||||
if (response.response_code() != Response::RespOk) {
|
if (response.response_code() != Response::RespOk) {
|
||||||
|
cachedAltsCount = -1;
|
||||||
|
altsCountValue->setText(QStringLiteral("–"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext);
|
const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext);
|
||||||
|
// The server always includes the frontier user as a depth-0 seed row.
|
||||||
|
int relatedCount = 0;
|
||||||
|
for (int i = 0; i < resp.alts_size(); ++i) {
|
||||||
|
if (!resp.alts(i).has_depth() || resp.alts(i).depth() > 0) {
|
||||||
|
++relatedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedAltsCount = relatedCount;
|
||||||
|
applyAltsCount();
|
||||||
altsTable->setRowCount(resp.alts_size());
|
altsTable->setRowCount(resp.alts_size());
|
||||||
for (int i = 0; i < resp.alts_size(); ++i) {
|
for (int i = 0; i < resp.alts_size(); ++i) {
|
||||||
const ServerInfo_UserAlt &alt = resp.alts(i);
|
const ServerInfo_UserAlt &alt = resp.alts(i);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
|
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
class AbstractClient;
|
class AbstractClient;
|
||||||
|
|
@ -13,6 +14,7 @@ class QLabel;
|
||||||
class QLineEdit;
|
class QLineEdit;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class QTableWidget;
|
class QTableWidget;
|
||||||
|
class QTabWidget;
|
||||||
class QTextEdit;
|
class QTextEdit;
|
||||||
class Response;
|
class Response;
|
||||||
class TabSupervisor;
|
class TabSupervisor;
|
||||||
|
|
@ -36,6 +38,7 @@ public:
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void investigateUser();
|
void investigateUser();
|
||||||
|
void openAltsGraph();
|
||||||
void userInfoResponse(const Response &response);
|
void userInfoResponse(const Response &response);
|
||||||
void sessionsResponse(const Response &response);
|
void sessionsResponse(const Response &response);
|
||||||
void altsResponse(const Response &response);
|
void altsResponse(const Response &response);
|
||||||
|
|
@ -53,10 +56,15 @@ private:
|
||||||
void requestAlts(const QString &userName);
|
void requestAlts(const QString &userName);
|
||||||
void requestModeratorLogins();
|
void requestModeratorLogins();
|
||||||
void clearUserData();
|
void clearUserData();
|
||||||
|
void applyUserInfo();
|
||||||
|
void applyAltsCount();
|
||||||
[[nodiscard]] QString formatEpoch(quint64 ts) const;
|
[[nodiscard]] QString formatEpoch(quint64 ts) const;
|
||||||
|
|
||||||
AbstractClient *client;
|
AbstractClient *client;
|
||||||
QString currentUser;
|
QString currentUser;
|
||||||
|
Response_ReportUserInfo cachedUserInfo;
|
||||||
|
bool cachedUserInfoValid = false;
|
||||||
|
int cachedAltsCount = -1;
|
||||||
|
|
||||||
QLineEdit *searchEdit;
|
QLineEdit *searchEdit;
|
||||||
QPushButton *investigateButton;
|
QPushButton *investigateButton;
|
||||||
|
|
@ -65,25 +73,25 @@ private:
|
||||||
QPushButton *removeBannerButton;
|
QPushButton *removeBannerButton;
|
||||||
|
|
||||||
QGroupBox *userInfoGroup;
|
QGroupBox *userInfoGroup;
|
||||||
QLabel *userInfoNameLabel;
|
|
||||||
QLabel *userInfoNameValue;
|
QLabel *userInfoNameValue;
|
||||||
QLabel *userInfoRegisteredLabel;
|
|
||||||
QLabel *userInfoRegisteredValue;
|
|
||||||
QLabel *userInfoLastLoginLabel;
|
|
||||||
QLabel *userInfoLastLoginValue;
|
|
||||||
QLabel *userInfoStatusLabel;
|
|
||||||
QLabel *userInfoStatusValue;
|
QLabel *userInfoStatusValue;
|
||||||
QLabel *userInfoCountsLabel;
|
QLabel *sublineLabel;
|
||||||
QLabel *userInfoCountsValue;
|
QGroupBox *cardReports;
|
||||||
|
QGroupBox *cardWarns;
|
||||||
|
QGroupBox *cardBans;
|
||||||
|
QGroupBox *altsCard;
|
||||||
|
QLabel *cardReportsValue;
|
||||||
|
QLabel *cardWarnsValue;
|
||||||
|
QLabel *cardBansValue;
|
||||||
|
QLabel *altsCountValue;
|
||||||
QLabel *userInfoNotesLabel;
|
QLabel *userInfoNotesLabel;
|
||||||
QTextEdit *userInfoNotesEdit;
|
QTextEdit *userInfoNotesEdit;
|
||||||
|
|
||||||
QGroupBox *altsGroup;
|
QTabWidget *sectionTabs;
|
||||||
QTableWidget *altsTable;
|
QTableWidget *altsTable;
|
||||||
QGroupBox *sessionsGroup;
|
|
||||||
QTableWidget *sessionsTable;
|
QTableWidget *sessionsTable;
|
||||||
QGroupBox *staffGroup;
|
|
||||||
QTableWidget *staffTable;
|
QTableWidget *staffTable;
|
||||||
|
QPushButton *openAltsButton;
|
||||||
QPushButton *refreshStaffButton;
|
QPushButton *refreshStaffButton;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
#include "../utility/report_utils.h"
|
#include "../utility/report_utils.h"
|
||||||
#include "abstract_client.h"
|
#include "abstract_client.h"
|
||||||
#include "libcockatrice/protocol/pb/response.pb.h"
|
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||||
|
#include "report_status_delegate.h"
|
||||||
#include "tab_supervisor.h"
|
#include "tab_supervisor.h"
|
||||||
|
|
||||||
#include <QAbstractItemView>
|
#include <QAbstractItemView>
|
||||||
|
|
@ -24,7 +25,7 @@
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
#include <QSignalBlocker>
|
#include <QSignalBlocker>
|
||||||
#include <QSplitter>
|
#include <QTabWidget>
|
||||||
#include <QTableWidget>
|
#include <QTableWidget>
|
||||||
#include <QTableWidgetItem>
|
#include <QTableWidgetItem>
|
||||||
#include <QTextCursor>
|
#include <QTextCursor>
|
||||||
|
|
@ -126,26 +127,43 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
||||||
connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged);
|
connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged);
|
||||||
table->setContextMenuPolicy(Qt::CustomContextMenu);
|
table->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||||
connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu);
|
connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu);
|
||||||
|
table->setItemDelegateForColumn(COL_STATUS, new ReportStatusDelegate(table));
|
||||||
userContextMenu = new UserContextMenu(tabSupervisor, this);
|
userContextMenu = new UserContextMenu(tabSupervisor, this);
|
||||||
connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog);
|
connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog);
|
||||||
|
|
||||||
descGroup = new QGroupBox;
|
reportHeaderName = new QLabel;
|
||||||
|
QFont displayFont = reportHeaderName->font();
|
||||||
|
displayFont.setPointSize(displayFont.pointSize() + 6);
|
||||||
|
displayFont.setBold(true);
|
||||||
|
reportHeaderName->setFont(displayFont);
|
||||||
|
reportHeaderStatus = new QLabel;
|
||||||
|
QFont statusFont = reportHeaderStatus->font();
|
||||||
|
statusFont.setBold(true);
|
||||||
|
reportHeaderStatus->setFont(statusFont);
|
||||||
|
reportHeaderSubline = new QLabel;
|
||||||
|
|
||||||
|
auto *reportIdentityRow = new QHBoxLayout;
|
||||||
|
reportIdentityRow->addWidget(reportHeaderName);
|
||||||
|
reportIdentityRow->addStretch();
|
||||||
|
reportIdentityRow->addWidget(reportHeaderStatus);
|
||||||
|
|
||||||
|
auto *descPage = new QWidget;
|
||||||
descriptionEdit = new QTextEdit;
|
descriptionEdit = new QTextEdit;
|
||||||
descriptionEdit->setReadOnly(true);
|
descriptionEdit->setReadOnly(true);
|
||||||
auto *descLayout = new QVBoxLayout(descGroup);
|
auto *descLayout = new QVBoxLayout(descPage);
|
||||||
descLayout->setContentsMargins(4, 4, 4, 4);
|
descLayout->setContentsMargins(4, 4, 4, 4);
|
||||||
descLayout->addWidget(descriptionEdit);
|
descLayout->addWidget(descriptionEdit);
|
||||||
|
|
||||||
chatGroup = new QGroupBox;
|
auto *chatPage = new QWidget;
|
||||||
chatLogEdit = new QTextEdit;
|
chatLogEdit = new QTextEdit;
|
||||||
chatLogEdit->setReadOnly(true);
|
chatLogEdit->setReadOnly(true);
|
||||||
applyChatLogFont();
|
applyChatLogFont();
|
||||||
connect(themeManager, &ThemeManager::fontsChanged, this, &TabReport::applyChatLogFont);
|
connect(themeManager, &ThemeManager::fontsChanged, this, &TabReport::applyChatLogFont);
|
||||||
auto *chatLayout = new QVBoxLayout(chatGroup);
|
auto *chatLayout = new QVBoxLayout(chatPage);
|
||||||
chatLayout->setContentsMargins(4, 4, 4, 4);
|
chatLayout->setContentsMargins(4, 4, 4, 4);
|
||||||
chatLayout->addWidget(chatLogEdit);
|
chatLayout->addWidget(chatLogEdit);
|
||||||
|
|
||||||
commentsGroup = new QGroupBox;
|
auto *commentsPage = new QWidget;
|
||||||
commentsEdit = new QTextEdit;
|
commentsEdit = new QTextEdit;
|
||||||
commentsEdit->setReadOnly(true);
|
commentsEdit->setReadOnly(true);
|
||||||
auto *commentInputRow = new QHBoxLayout;
|
auto *commentInputRow = new QHBoxLayout;
|
||||||
|
|
@ -156,12 +174,12 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
||||||
connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment);
|
connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment);
|
||||||
commentInputRow->addWidget(commentInput);
|
commentInputRow->addWidget(commentInput);
|
||||||
commentInputRow->addWidget(commentButton);
|
commentInputRow->addWidget(commentButton);
|
||||||
auto *commentsLayout = new QVBoxLayout(commentsGroup);
|
auto *commentsLayout = new QVBoxLayout(commentsPage);
|
||||||
commentsLayout->setContentsMargins(4, 4, 4, 4);
|
commentsLayout->setContentsMargins(4, 4, 4, 4);
|
||||||
commentsLayout->addWidget(commentsEdit);
|
commentsLayout->addWidget(commentsEdit);
|
||||||
commentsLayout->addLayout(commentInputRow);
|
commentsLayout->addLayout(commentInputRow);
|
||||||
|
|
||||||
userContextGroup = new QGroupBox;
|
auto *userPage = new QWidget;
|
||||||
userContextName = new QLabel;
|
userContextName = new QLabel;
|
||||||
userContextAccountAge = new QLabel;
|
userContextAccountAge = new QLabel;
|
||||||
userContextReports = new QLabel;
|
userContextReports = new QLabel;
|
||||||
|
|
@ -180,7 +198,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
||||||
userContextWarnsLabel = new QLabel;
|
userContextWarnsLabel = new QLabel;
|
||||||
userContextNotesLabel = new QLabel;
|
userContextNotesLabel = new QLabel;
|
||||||
userContextRecentReportsLabel = new QLabel;
|
userContextRecentReportsLabel = new QLabel;
|
||||||
auto *ucLayout = new QGridLayout(userContextGroup);
|
auto *ucLayout = new QGridLayout(userPage);
|
||||||
ucLayout->setContentsMargins(8, 8, 8, 8);
|
ucLayout->setContentsMargins(8, 8, 8, 8);
|
||||||
ucLayout->addWidget(userContextUserLabel, 0, 0);
|
ucLayout->addWidget(userContextUserLabel, 0, 0);
|
||||||
ucLayout->addWidget(userContextName, 0, 1, 1, 3);
|
ucLayout->addWidget(userContextName, 0, 1, 1, 3);
|
||||||
|
|
@ -196,7 +214,6 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
||||||
ucLayout->addWidget(userContextNotes, 3, 1, 1, 3);
|
ucLayout->addWidget(userContextNotes, 3, 1, 1, 3);
|
||||||
ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4);
|
ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4);
|
||||||
ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4);
|
ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4);
|
||||||
userContextGroup->setVisible(false);
|
|
||||||
|
|
||||||
statsGroup = new QGroupBox;
|
statsGroup = new QGroupBox;
|
||||||
statsGroup->setCheckable(true);
|
statsGroup->setCheckable(true);
|
||||||
|
|
@ -223,13 +240,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
detailSplitter = new QSplitter(Qt::Vertical);
|
detailTabs = new QTabWidget;
|
||||||
detailSplitter->addWidget(descGroup);
|
detailTabs->addTab(descPage, QString());
|
||||||
detailSplitter->addWidget(chatGroup);
|
detailTabs->addTab(chatPage, QString());
|
||||||
detailSplitter->addWidget(commentsGroup);
|
detailTabs->addTab(commentsPage, QString());
|
||||||
detailSplitter->setStretchFactor(0, 1);
|
detailTabs->addTab(userPage, QString());
|
||||||
detailSplitter->setStretchFactor(1, 1);
|
|
||||||
detailSplitter->setStretchFactor(2, 2);
|
|
||||||
|
|
||||||
assignButton = new QPushButton;
|
assignButton = new QPushButton;
|
||||||
resolveButton = new QPushButton;
|
resolveButton = new QPushButton;
|
||||||
|
|
@ -261,12 +276,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
||||||
layout->addLayout(topBar);
|
layout->addLayout(topBar);
|
||||||
layout->addWidget(statsLabel);
|
layout->addWidget(statsLabel);
|
||||||
layout->addWidget(table, 2);
|
layout->addWidget(table, 2);
|
||||||
layout->addWidget(detailSplitter, 1);
|
layout->addLayout(reportIdentityRow);
|
||||||
layout->addWidget(userContextGroup);
|
layout->addWidget(reportHeaderSubline);
|
||||||
|
layout->addWidget(detailTabs, 2);
|
||||||
layout->addWidget(statsGroup);
|
layout->addWidget(statsGroup);
|
||||||
layout->addLayout(actionBar);
|
layout->addLayout(actionBar);
|
||||||
|
|
||||||
retranslateUi();
|
retranslateUi();
|
||||||
|
clearReportHeader();
|
||||||
|
|
||||||
setActionsEnabled(false);
|
setActionsEnabled(false);
|
||||||
refreshList();
|
refreshList();
|
||||||
|
|
@ -293,18 +310,18 @@ void TabReport::retranslateUi()
|
||||||
table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"),
|
table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"),
|
||||||
tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")});
|
tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")});
|
||||||
|
|
||||||
descGroup->setTitle(tr("Description"));
|
detailTabs->setTabText(0, tr("Description"));
|
||||||
chatGroup->setTitle(tr("Chat Log Context"));
|
detailTabs->setTabText(1, tr("Chat Log Context"));
|
||||||
chatGroup->setToolTip(
|
detailTabs->setTabToolTip(1, tr("Chat log attached by the reporter. It is captured from their client and may be "
|
||||||
tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited."));
|
"incomplete or edited."));
|
||||||
commentsGroup->setTitle(tr("Comments / Thread"));
|
detailTabs->setTabText(2, tr("Comments / Thread"));
|
||||||
|
detailTabs->setTabText(3, tr("Reported User"));
|
||||||
descriptionEdit->setPlaceholderText(tr("No description."));
|
descriptionEdit->setPlaceholderText(tr("No description."));
|
||||||
chatLogEdit->setPlaceholderText(tr("No chat log attached."));
|
chatLogEdit->setPlaceholderText(tr("No chat log attached."));
|
||||||
commentsEdit->setPlaceholderText(tr("No comments yet."));
|
commentsEdit->setPlaceholderText(tr("No comments yet."));
|
||||||
commentInput->setPlaceholderText(tr("Type a reply..."));
|
commentInput->setPlaceholderText(tr("Type a reply..."));
|
||||||
commentButton->setText(tr("Send"));
|
commentButton->setText(tr("Send"));
|
||||||
|
|
||||||
userContextGroup->setTitle(tr("Reported User Context"));
|
|
||||||
userContextUserLabel->setText(tr("User:"));
|
userContextUserLabel->setText(tr("User:"));
|
||||||
userContextAgeLabel->setText(tr("Account Age:"));
|
userContextAgeLabel->setText(tr("Account Age:"));
|
||||||
userContextReportsLabel->setText(tr("Reports:"));
|
userContextReportsLabel->setText(tr("Reports:"));
|
||||||
|
|
@ -312,10 +329,18 @@ void TabReport::retranslateUi()
|
||||||
userContextWarnsLabel->setText(tr("Warns:"));
|
userContextWarnsLabel->setText(tr("Warns:"));
|
||||||
userContextNotesLabel->setText(tr("Admin Notes:"));
|
userContextNotesLabel->setText(tr("Admin Notes:"));
|
||||||
userContextRecentReportsLabel->setText(tr("Recent Reports Against User:"));
|
userContextRecentReportsLabel->setText(tr("Recent Reports Against User:"));
|
||||||
userContextAccountAge->setText(QString());
|
|
||||||
userContextReports->setText(QString());
|
if (headerReportId >= 0) {
|
||||||
userContextBans->setText(QString());
|
for (const ServerInfo_Report &r : filteredReports) {
|
||||||
userContextWarns->setText(QString());
|
if (r.report_id() == headerReportId) {
|
||||||
|
updateReportHeader(r);
|
||||||
|
const QString status = QString::fromStdString(r.status());
|
||||||
|
const bool canComment = (status == "open" || status == "assigned");
|
||||||
|
commentInput->setPlaceholderText(canComment ? tr("Type a reply...") : tr("This report is closed."));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
statsGroup->setTitle(tr("Report Statistics"));
|
statsGroup->setTitle(tr("Report Statistics"));
|
||||||
|
|
||||||
|
|
@ -408,7 +433,8 @@ void TabReport::reportListResponse(const Response &response)
|
||||||
chatLogEdit->clear();
|
chatLogEdit->clear();
|
||||||
commentsEdit->clear();
|
commentsEdit->clear();
|
||||||
setActionsEnabled(false);
|
setActionsEnabled(false);
|
||||||
userContextGroup->setVisible(false);
|
clearReportHeader();
|
||||||
|
clearUserContext();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,11 +471,14 @@ void TabReport::applyFilters()
|
||||||
table->setSortingEnabled(false);
|
table->setSortingEnabled(false);
|
||||||
table->setRowCount(filteredReports.size());
|
table->setRowCount(filteredReports.size());
|
||||||
|
|
||||||
|
QFont reportedFont = table->font();
|
||||||
|
reportedFont.setBold(true);
|
||||||
|
|
||||||
for (int row = 0; row < filteredReports.size(); ++row) {
|
for (int row = 0; row < filteredReports.size(); ++row) {
|
||||||
const ServerInfo_Report &r = filteredReports[row];
|
const ServerInfo_Report &r = filteredReports[row];
|
||||||
|
|
||||||
report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID,
|
report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID,
|
||||||
COL_STATUS, COL_ASSIGNED);
|
COL_STATUS, COL_ASSIGNED, false);
|
||||||
|
|
||||||
table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name())));
|
table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name())));
|
||||||
|
|
||||||
|
|
@ -458,12 +487,30 @@ void TabReport::applyFilters()
|
||||||
|
|
||||||
table->setItem(row, COL_ROOM,
|
table->setItem(row, COL_ROOM,
|
||||||
new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : ""));
|
new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : ""));
|
||||||
|
|
||||||
|
if (auto *reportedItem = table->item(row, COL_REPORTED)) {
|
||||||
|
reportedItem->setFont(reportedFont);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
table->setSortingEnabled(true);
|
table->setSortingEnabled(true);
|
||||||
table->resizeColumnsToContents();
|
table->resizeColumnsToContents();
|
||||||
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
|
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
|
||||||
|
|
||||||
|
if (headerReportId >= 0) {
|
||||||
|
bool stillVisible = false;
|
||||||
|
for (const ServerInfo_Report &r : filteredReports) {
|
||||||
|
if (r.report_id() == headerReportId) {
|
||||||
|
stillVisible = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!stillVisible) {
|
||||||
|
clearReportHeader();
|
||||||
|
clearUserContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size()));
|
statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -490,6 +537,31 @@ void TabReport::updateStats()
|
||||||
.arg(dismissed));
|
.arg(dismissed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TabReport::updateReportHeader(const ServerInfo_Report &report)
|
||||||
|
{
|
||||||
|
reportHeaderName->setText(QString::fromStdString(report.reported_user_name()));
|
||||||
|
reportHeaderStatus->setText(report_utils::formatReportStatus(QString::fromStdString(report.status())));
|
||||||
|
|
||||||
|
QStringList parts;
|
||||||
|
parts << QString::fromStdString(report.category());
|
||||||
|
parts << QDateTime::fromSecsSinceEpoch(report.report_time()).toLocalTime().toString("yyyy-MM-dd HH:mm");
|
||||||
|
if (report.has_room_id() && report.room_id() > 0) {
|
||||||
|
parts << tr("Room %1").arg(report.room_id());
|
||||||
|
}
|
||||||
|
if (report.game_id() > 0) {
|
||||||
|
parts << tr("Game %1").arg(report.game_id());
|
||||||
|
}
|
||||||
|
reportHeaderSubline->setText(parts.join(QStringLiteral(" · ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabReport::clearReportHeader()
|
||||||
|
{
|
||||||
|
headerReportId = -1;
|
||||||
|
reportHeaderName->setText(tr("Select a report to inspect."));
|
||||||
|
reportHeaderStatus->clear();
|
||||||
|
reportHeaderSubline->clear();
|
||||||
|
}
|
||||||
|
|
||||||
void TabReport::onSelectionChanged()
|
void TabReport::onSelectionChanged()
|
||||||
{
|
{
|
||||||
const int row = table->currentRow();
|
const int row = table->currentRow();
|
||||||
|
|
@ -500,7 +572,8 @@ void TabReport::onSelectionChanged()
|
||||||
commentInput->clear();
|
commentInput->clear();
|
||||||
commentButton->setEnabled(false);
|
commentButton->setEnabled(false);
|
||||||
setActionsEnabled(false);
|
setActionsEnabled(false);
|
||||||
userContextGroup->setVisible(false);
|
clearReportHeader();
|
||||||
|
clearUserContext();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,6 +582,18 @@ void TabReport::onSelectionChanged()
|
||||||
updateActionStates();
|
updateActionStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TabReport::clearUserContext()
|
||||||
|
{
|
||||||
|
lastRequestedUser.clear();
|
||||||
|
userContextName->setText(tr("—"));
|
||||||
|
userContextAccountAge->clear();
|
||||||
|
userContextReports->clear();
|
||||||
|
userContextBans->clear();
|
||||||
|
userContextWarns->clear();
|
||||||
|
userContextNotes->setPlainText(tr("Select a report to inspect."));
|
||||||
|
userContextRecentReports->clear();
|
||||||
|
}
|
||||||
|
|
||||||
void TabReport::showTableContextMenu(const QPoint &pos)
|
void TabReport::showTableContextMenu(const QPoint &pos)
|
||||||
{
|
{
|
||||||
QTableWidgetItem *clickedItem = table->itemAt(pos);
|
QTableWidgetItem *clickedItem = table->itemAt(pos);
|
||||||
|
|
@ -546,12 +631,17 @@ void TabReport::loadReportDetails(int reportId)
|
||||||
for (const ServerInfo_Report &r : filteredReports) {
|
for (const ServerInfo_Report &r : filteredReports) {
|
||||||
if (r.report_id() == reportId) {
|
if (r.report_id() == reportId) {
|
||||||
descriptionEdit->setPlainText(QString::fromStdString(r.description()));
|
descriptionEdit->setPlainText(QString::fromStdString(r.description()));
|
||||||
|
updateReportHeader(r);
|
||||||
|
if (headerReportId != reportId) {
|
||||||
|
headerReportId = reportId;
|
||||||
|
detailTabs->setCurrentIndex(0);
|
||||||
|
}
|
||||||
|
|
||||||
QString reportedUser = QString::fromStdString(r.reported_user_name());
|
QString reportedUser = QString::fromStdString(r.reported_user_name());
|
||||||
if (!reportedUser.isEmpty()) {
|
if (!reportedUser.isEmpty()) {
|
||||||
requestUserInfo(reportedUser);
|
requestUserInfo(reportedUser);
|
||||||
} else {
|
} else {
|
||||||
userContextGroup->setVisible(false);
|
clearUserContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
QString status = QString::fromStdString(r.status());
|
QString status = QString::fromStdString(r.status());
|
||||||
|
|
@ -871,12 +961,11 @@ void TabReport::addCommentResponse(const Response &response)
|
||||||
void TabReport::requestUserInfo(const QString &userName)
|
void TabReport::requestUserInfo(const QString &userName)
|
||||||
{
|
{
|
||||||
if (userName.isEmpty()) {
|
if (userName.isEmpty()) {
|
||||||
userContextGroup->setVisible(false);
|
clearUserContext();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastRequestedUser = userName;
|
lastRequestedUser = userName;
|
||||||
userContextGroup->setVisible(true);
|
|
||||||
userContextName->setText(userName);
|
userContextName->setText(userName);
|
||||||
userContextAccountAge->setText(tr("Loading..."));
|
userContextAccountAge->setText(tr("Loading..."));
|
||||||
userContextReports->clear();
|
userContextReports->clear();
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ class QGroupBox;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
class QLineEdit;
|
class QLineEdit;
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class QSplitter;
|
|
||||||
class QTableWidget;
|
class QTableWidget;
|
||||||
|
class QTabWidget;
|
||||||
class QTextEdit;
|
class QTextEdit;
|
||||||
class QTimer;
|
class QTimer;
|
||||||
class GameReplay;
|
class GameReplay;
|
||||||
|
|
@ -72,6 +72,9 @@ private:
|
||||||
void applyFilters();
|
void applyFilters();
|
||||||
void updateStats();
|
void updateStats();
|
||||||
void loadReportDetails(int reportId);
|
void loadReportDetails(int reportId);
|
||||||
|
void updateReportHeader(const ServerInfo_Report &report);
|
||||||
|
void clearReportHeader();
|
||||||
|
void clearUserContext();
|
||||||
|
|
||||||
AbstractClient *client;
|
AbstractClient *client;
|
||||||
|
|
||||||
|
|
@ -81,10 +84,10 @@ private:
|
||||||
QPushButton *refreshButton;
|
QPushButton *refreshButton;
|
||||||
QLabel *statsLabel;
|
QLabel *statsLabel;
|
||||||
QTableWidget *table;
|
QTableWidget *table;
|
||||||
QSplitter *detailSplitter;
|
QLabel *reportHeaderName;
|
||||||
QGroupBox *descGroup;
|
QLabel *reportHeaderStatus;
|
||||||
QGroupBox *chatGroup;
|
QLabel *reportHeaderSubline;
|
||||||
QGroupBox *commentsGroup;
|
QTabWidget *detailTabs;
|
||||||
QTextEdit *descriptionEdit;
|
QTextEdit *descriptionEdit;
|
||||||
QTextEdit *chatLogEdit;
|
QTextEdit *chatLogEdit;
|
||||||
QTextEdit *commentsEdit;
|
QTextEdit *commentsEdit;
|
||||||
|
|
@ -100,7 +103,6 @@ private:
|
||||||
QTimer *refreshTimer;
|
QTimer *refreshTimer;
|
||||||
UserContextMenu *userContextMenu;
|
UserContextMenu *userContextMenu;
|
||||||
|
|
||||||
QGroupBox *userContextGroup;
|
|
||||||
QLabel *userContextName;
|
QLabel *userContextName;
|
||||||
QLabel *userContextAccountAge;
|
QLabel *userContextAccountAge;
|
||||||
QLabel *userContextReports;
|
QLabel *userContextReports;
|
||||||
|
|
@ -125,6 +127,7 @@ private:
|
||||||
|
|
||||||
QList<ServerInfo_Report> allReports;
|
QList<ServerInfo_Report> allReports;
|
||||||
QList<ServerInfo_Report> filteredReports;
|
QList<ServerInfo_Report> filteredReports;
|
||||||
|
int headerReportId = -1;
|
||||||
int detailsRequestedReportId = -1;
|
int detailsRequestedReportId = -1;
|
||||||
int detailsRequestSeq = 0;
|
int detailsRequestSeq = 0;
|
||||||
int selectedReportIdBeforeRefresh = -1;
|
int selectedReportIdBeforeRefresh = -1;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
#include "tab.h"
|
#include "tab.h"
|
||||||
#include "tab_account.h"
|
#include "tab_account.h"
|
||||||
#include "tab_admin.h"
|
#include "tab_admin.h"
|
||||||
|
#include "tab_alts.h"
|
||||||
#include "tab_card_art_rules.h"
|
#include "tab_card_art_rules.h"
|
||||||
#include "tab_deck_editor.h"
|
#include "tab_deck_editor.h"
|
||||||
#include "tab_deck_storage.h"
|
#include "tab_deck_storage.h"
|
||||||
|
|
@ -157,7 +158,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
|
||||||
: QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr),
|
: QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr),
|
||||||
tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr),
|
tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr),
|
||||||
tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr),
|
tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr),
|
||||||
tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false)
|
tabModeration(nullptr), tabAlts(nullptr), tabDeveloper(nullptr), isLocalGame(false)
|
||||||
{
|
{
|
||||||
setObjectName("tabSupervisor");
|
setObjectName("tabSupervisor");
|
||||||
|
|
||||||
|
|
@ -575,6 +576,7 @@ void TabSupervisor::startLocal(const QList<AbstractClient *> &_clients)
|
||||||
tabLog = nullptr;
|
tabLog = nullptr;
|
||||||
tabReport = nullptr;
|
tabReport = nullptr;
|
||||||
tabModeration = nullptr;
|
tabModeration = nullptr;
|
||||||
|
tabAlts = nullptr;
|
||||||
tabDeveloper = nullptr;
|
tabDeveloper = nullptr;
|
||||||
isLocalGame = true;
|
isLocalGame = true;
|
||||||
userInfo = new ServerInfo_User;
|
userInfo = new ServerInfo_User;
|
||||||
|
|
@ -623,6 +625,9 @@ void TabSupervisor::stop()
|
||||||
if (tabModeration) {
|
if (tabModeration) {
|
||||||
tabModeration->close();
|
tabModeration->close();
|
||||||
}
|
}
|
||||||
|
if (tabAlts) {
|
||||||
|
tabAlts->close();
|
||||||
|
}
|
||||||
if (tabCardArtRules) {
|
if (tabCardArtRules) {
|
||||||
tabCardArtRules->close();
|
tabCardArtRules->close();
|
||||||
}
|
}
|
||||||
|
|
@ -931,6 +936,21 @@ void TabSupervisor::openTabModeration(const QString &userName)
|
||||||
aTabModeration->setChecked(true);
|
aTabModeration->setChecked(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TabSupervisor::openTabAlts(const QString &userName)
|
||||||
|
{
|
||||||
|
if (tabAlts) {
|
||||||
|
setCurrentWidget(tabAlts);
|
||||||
|
if (!userName.isEmpty()) {
|
||||||
|
tabAlts->investigate(userName);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabAlts = new TabAlts(this, client, userName);
|
||||||
|
myAddTab(tabAlts);
|
||||||
|
setCurrentWidget(tabAlts);
|
||||||
|
connect(tabAlts, &QObject::destroyed, this, [this] { tabAlts = nullptr; });
|
||||||
|
}
|
||||||
|
|
||||||
void TabSupervisor::actTabDeveloper(bool checked)
|
void TabSupervisor::actTabDeveloper(bool checked)
|
||||||
{
|
{
|
||||||
if (checked && !tabDeveloper) {
|
if (checked && !tabDeveloper) {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ class TabAdmin;
|
||||||
class TabMessage;
|
class TabMessage;
|
||||||
class TabReport;
|
class TabReport;
|
||||||
class TabModeration;
|
class TabModeration;
|
||||||
|
class TabAlts;
|
||||||
class TabAccount;
|
class TabAccount;
|
||||||
class TabDeckEditor;
|
class TabDeckEditor;
|
||||||
class TabDeveloper;
|
class TabDeveloper;
|
||||||
|
|
@ -116,6 +117,7 @@ private:
|
||||||
TabLog *tabLog;
|
TabLog *tabLog;
|
||||||
TabReport *tabReport;
|
TabReport *tabReport;
|
||||||
TabModeration *tabModeration;
|
TabModeration *tabModeration;
|
||||||
|
TabAlts *tabAlts;
|
||||||
TabDeveloper *tabDeveloper;
|
TabDeveloper *tabDeveloper;
|
||||||
QMap<int, TabRoom *> roomTabs;
|
QMap<int, TabRoom *> roomTabs;
|
||||||
QMap<int, TabGame *> gameTabs;
|
QMap<int, TabGame *> gameTabs;
|
||||||
|
|
@ -209,6 +211,7 @@ public slots:
|
||||||
/** Joins or spectates a game listed on a user's profile hover card. */
|
/** Joins or spectates a game listed on a user's profile hover card. */
|
||||||
void joinGameFromUserCard(int gameId, int roomId, bool asSpectator);
|
void joinGameFromUserCard(int gameId, int roomId, bool asSpectator);
|
||||||
void openTabModeration(const QString &userName = {});
|
void openTabModeration(const QString &userName = {});
|
||||||
|
void openTabAlts(const QString &userName = {});
|
||||||
void switchToFirstAvailableNetworkTab();
|
void switchToFirstAvailableNetworkTab();
|
||||||
void maximizeMainWindow();
|
void maximizeMainWindow();
|
||||||
void actTabVisualDeckStorage(bool checked);
|
void actTabVisualDeckStorage(bool checked);
|
||||||
|
|
|
||||||
27
cockatrice/src/interface/widgets/utility/epoch_format.h
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* @file epoch_format.h
|
||||||
|
* @brief Shared timestamp formatting for the moderation tables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef EPOCH_FORMAT_H
|
||||||
|
#define EPOCH_FORMAT_H
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Format a unix timestamp the way the moderation tabs show dates.
|
||||||
|
*
|
||||||
|
* @param ts Seconds since the epoch; 0 means "unknown".
|
||||||
|
* @param unknownText Label returned for 0, translated by the caller so the
|
||||||
|
* translation context stays with the tab.
|
||||||
|
*/
|
||||||
|
inline QString formatEpochTimestamp(quint64 ts, const QString &unknownText)
|
||||||
|
{
|
||||||
|
if (ts == 0) {
|
||||||
|
return unknownText;
|
||||||
|
}
|
||||||
|
return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // EPOCH_FORMAT_H
|
||||||
41
cockatrice/src/interface/widgets/utility/pill_painter.cpp
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
#include "pill_painter.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QFontMetrics>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QRect>
|
||||||
|
#include <QStyle>
|
||||||
|
#include <QStyleOptionViewItem>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr qreal PILL_ALPHA = 0.30;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool pill_painter::paint(QPainter *painter,
|
||||||
|
const QStyleOptionViewItem &option,
|
||||||
|
QPalette::ColorRole tintRole,
|
||||||
|
QPalette::ColorRole textRole,
|
||||||
|
const QString &text)
|
||||||
|
{
|
||||||
|
const QRect pillRect = option.rect.adjusted(PAD_X / 2, PAD_Y / 2, -PAD_X / 2, -PAD_Y / 2);
|
||||||
|
if (pillRect.width() <= 0 || pillRect.height() <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((option.state & QStyle::State_Selected) == 0) {
|
||||||
|
painter->save();
|
||||||
|
painter->setRenderHint(QPainter::Antialiasing, true);
|
||||||
|
QColor pill = option.palette.color(tintRole);
|
||||||
|
pill.setAlphaF(PILL_ALPHA);
|
||||||
|
painter->setPen(Qt::NoPen);
|
||||||
|
painter->setBrush(pill);
|
||||||
|
painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0);
|
||||||
|
painter->restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
QStyle *style = option.widget != nullptr ? option.widget->style() : QApplication::style();
|
||||||
|
const QString elided = option.fontMetrics.elidedText(text, option.textElideMode, pillRect.width());
|
||||||
|
style->drawItemText(painter, pillRect, Qt::AlignCenter, option.palette, true, elided, textRole);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
29
cockatrice/src/interface/widgets/utility/pill_painter.h
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#ifndef PILL_PAINTER_H
|
||||||
|
#define PILL_PAINTER_H
|
||||||
|
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class QPainter;
|
||||||
|
class QStyleOptionViewItem;
|
||||||
|
|
||||||
|
namespace pill_painter
|
||||||
|
{
|
||||||
|
constexpr int PAD_X = 10;
|
||||||
|
constexpr int PAD_Y = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws a translucent, palette-tinted rounded pill inside the option's rect and
|
||||||
|
* paints the text elided and centred on it, so badges follow the active theme.
|
||||||
|
* Returns false when the cell is too small to hold a pill, leaving the caller to
|
||||||
|
* fall back to default painting.
|
||||||
|
*/
|
||||||
|
bool paint(QPainter *painter,
|
||||||
|
const QStyleOptionViewItem &option,
|
||||||
|
QPalette::ColorRole tintRole,
|
||||||
|
QPalette::ColorRole textRole,
|
||||||
|
const QString &text);
|
||||||
|
|
||||||
|
} // namespace pill_painter
|
||||||
|
|
||||||
|
#endif // PILL_PAINTER_H
|
||||||
|
|
@ -80,6 +80,23 @@ QString formatReportTime(qint64 secondsSinceEpoch)
|
||||||
return dt.toString("yyyy-MM-dd hh:mm");
|
return dt.toString("yyyy-MM-dd hh:mm");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString formatReportStatus(const QString &status)
|
||||||
|
{
|
||||||
|
if (status == QStringLiteral("open")) {
|
||||||
|
return QObject::tr("Open");
|
||||||
|
}
|
||||||
|
if (status == QStringLiteral("assigned")) {
|
||||||
|
return QObject::tr("Assigned");
|
||||||
|
}
|
||||||
|
if (status == QStringLiteral("resolved")) {
|
||||||
|
return QObject::tr("Resolved");
|
||||||
|
}
|
||||||
|
if (status == QStringLiteral("dismissed")) {
|
||||||
|
return QObject::tr("Dismissed");
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
void fillReportTableRow(QTableWidget *table,
|
void fillReportTableRow(QTableWidget *table,
|
||||||
int row,
|
int row,
|
||||||
const ServerInfo_Report &report,
|
const ServerInfo_Report &report,
|
||||||
|
|
@ -89,7 +106,8 @@ void fillReportTableRow(QTableWidget *table,
|
||||||
int categoryColumn,
|
int categoryColumn,
|
||||||
int gameIdColumn,
|
int gameIdColumn,
|
||||||
int statusColumn,
|
int statusColumn,
|
||||||
int assignedColumn)
|
int assignedColumn,
|
||||||
|
bool colorizeStatus)
|
||||||
{
|
{
|
||||||
auto *idItem = new QTableWidgetItem(QString::number(report.report_id()));
|
auto *idItem = new QTableWidgetItem(QString::number(report.report_id()));
|
||||||
idItem->setData(Qt::UserRole, report.report_id());
|
idItem->setData(Qt::UserRole, report.report_id());
|
||||||
|
|
@ -102,8 +120,12 @@ void fillReportTableRow(QTableWidget *table,
|
||||||
table->setItem(row, gameIdColumn,
|
table->setItem(row, gameIdColumn,
|
||||||
new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString()));
|
new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString()));
|
||||||
|
|
||||||
auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status()));
|
const QString rawStatus = QString::fromStdString(report.status());
|
||||||
setReportStatusItemColor(statusItem, QString::fromStdString(report.status()));
|
auto *statusItem = new QTableWidgetItem(formatReportStatus(rawStatus));
|
||||||
|
statusItem->setData(Qt::UserRole, rawStatus);
|
||||||
|
if (colorizeStatus) {
|
||||||
|
setReportStatusItemColor(statusItem, rawStatus);
|
||||||
|
}
|
||||||
table->setItem(row, statusColumn, statusItem);
|
table->setItem(row, statusColumn, statusItem);
|
||||||
|
|
||||||
table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name())));
|
table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name())));
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ QFont reportLogFont();
|
||||||
|
|
||||||
QString formatReportCategory(const QString &category);
|
QString formatReportCategory(const QString &category);
|
||||||
QString formatReportTime(qint64 secondsSinceEpoch);
|
QString formatReportTime(qint64 secondsSinceEpoch);
|
||||||
|
QString formatReportStatus(const QString &status);
|
||||||
|
|
||||||
void fillReportTableRow(QTableWidget *table,
|
void fillReportTableRow(QTableWidget *table,
|
||||||
int row,
|
int row,
|
||||||
|
|
@ -30,7 +31,8 @@ void fillReportTableRow(QTableWidget *table,
|
||||||
int categoryColumn,
|
int categoryColumn,
|
||||||
int gameIdColumn,
|
int gameIdColumn,
|
||||||
int statusColumn,
|
int statusColumn,
|
||||||
int assignedColumn);
|
int assignedColumn,
|
||||||
|
bool colorizeStatus = true);
|
||||||
|
|
||||||
void renderReportDetails(QTextEdit *chatLogEdit,
|
void renderReportDetails(QTextEdit *chatLogEdit,
|
||||||
QTextEdit *commentsEdit,
|
QTextEdit *commentsEdit,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
#include "table_activation.h"
|
||||||
|
|
||||||
|
#include <QKeySequence>
|
||||||
|
#include <QShortcut>
|
||||||
|
#include <QTableWidget>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
void addActivationShortcut(QTableWidget *table,
|
||||||
|
QObject *context,
|
||||||
|
int key,
|
||||||
|
const std::function<void(const QModelIndex &)> &handler)
|
||||||
|
{
|
||||||
|
auto *shortcut = new QShortcut(QKeySequence(key), table);
|
||||||
|
// The table's viewport takes focus, so match children as well.
|
||||||
|
shortcut->setContext(Qt::WidgetWithChildrenShortcut);
|
||||||
|
QObject::connect(shortcut, &QShortcut::activated, context, [table, handler] { handler(table->currentIndex()); });
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void connectTableActivation(QTableWidget *table,
|
||||||
|
QObject *context,
|
||||||
|
const std::function<void(const QModelIndex &)> &handler)
|
||||||
|
{
|
||||||
|
QObject::connect(table, &QTableWidget::doubleClicked, context, handler);
|
||||||
|
addActivationShortcut(table, context, Qt::Key_Return, handler);
|
||||||
|
addActivationShortcut(table, context, Qt::Key_Enter, handler);
|
||||||
|
}
|
||||||
32
cockatrice/src/interface/widgets/utility/table_activation.h
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
/**
|
||||||
|
* @file table_activation.h
|
||||||
|
* @brief Row activation for tables that must not navigate on a single click.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef TABLE_ACTIVATION_H
|
||||||
|
#define TABLE_ACTIVATION_H
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
class QModelIndex;
|
||||||
|
class QObject;
|
||||||
|
class QTableWidget;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Call @p handler on double-click or Enter, never on a single click.
|
||||||
|
*
|
||||||
|
* QAbstractItemView::activated fires on a single click on platforms where
|
||||||
|
* SH_ItemView_ActivateItemOnSingleClick is set, so navigation actions must not
|
||||||
|
* use it. This connects doubleClicked and installs Return/Enter shortcuts that
|
||||||
|
* forward the current index.
|
||||||
|
*
|
||||||
|
* @param table The table whose rows activate.
|
||||||
|
* @param context Object that owns the connections; the handler is disconnected
|
||||||
|
* with it.
|
||||||
|
* @param handler Receives the activated row's index, which may be invalid.
|
||||||
|
*/
|
||||||
|
void connectTableActivation(QTableWidget *table,
|
||||||
|
QObject *context,
|
||||||
|
const std::function<void(const QModelIndex &)> &handler);
|
||||||
|
|
||||||
|
#endif // TABLE_ACTIVATION_H
|
||||||
|
|
@ -15,9 +15,23 @@
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
#include <libcockatrice/filters/filter_tree.h>
|
#include <libcockatrice/filters/filter_tree.h>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
/** @brief Remove the filters that apply to the given color and attribute from the given model. */
|
||||||
|
static void removeFilters(FilterTreeModel *filterModel, QChar color, CardFilter::Attr attr)
|
||||||
|
{
|
||||||
|
for (const CardFilter *filter : filterModel->getFiltersOfType(attr)) {
|
||||||
|
if (filter->term() == color) {
|
||||||
|
filterModel->removeFilter(filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This widget provides a graphical control element for the CardFilter::Attr::AttrColor filters applied to the filter
|
* This widget provides a graphical control element for the CardFilter::Attr::AttrColor and
|
||||||
* model.
|
* CardFilter::Attr::AttrColorIdentity filters applied to the filter model.
|
||||||
* @param parent The Qt Widget that this widget will parent to
|
* @param parent The Qt Widget that this widget will parent to
|
||||||
* @param _filterModel The filter model that this widget will manipulate
|
* @param _filterModel The filter model that this widget will manipulate
|
||||||
*/
|
*/
|
||||||
|
|
@ -59,11 +73,25 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi()
|
||||||
modeComboBox->blockSignals(true);
|
modeComboBox->blockSignals(true);
|
||||||
modeComboBox->clear();
|
modeComboBox->clear();
|
||||||
|
|
||||||
modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch));
|
|
||||||
modeComboBox->addItem(tr("Includes"), QVariant::fromValue(FilterMode::Includes));
|
modeComboBox->addItem(tr("Includes"), QVariant::fromValue(FilterMode::Includes));
|
||||||
|
modeComboBox->addItem(tr("Excludes"), QVariant::fromValue(FilterMode::Excludes));
|
||||||
modeComboBox->addItem(tr("Include / Exclude"), QVariant::fromValue(FilterMode::IncludeExclude));
|
modeComboBox->addItem(tr("Include / Exclude"), QVariant::fromValue(FilterMode::IncludeExclude));
|
||||||
|
modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch));
|
||||||
|
modeComboBox->addItem(tr("Color identity"), QVariant::fromValue(FilterMode::ColorIdentity));
|
||||||
|
|
||||||
|
modeComboBox->setItemData(0, tr("Cards containing any of the selected colors"), Qt::ToolTipRole);
|
||||||
|
modeComboBox->setItemData(1, tr("Cards containing none of the selected colors"), Qt::ToolTipRole);
|
||||||
|
modeComboBox->setItemData(2, tr("Cards containing any of the selected colors and none of the unselected colors"),
|
||||||
|
Qt::ToolTipRole);
|
||||||
|
modeComboBox->setItemData(3, tr("Cards being exactly of the selected colors"), Qt::ToolTipRole);
|
||||||
|
modeComboBox->setItemData(4, tr("Cards whose color identity fits within the selected colors"), Qt::ToolTipRole);
|
||||||
|
|
||||||
|
if (currentMode == FilterMode::Custom) {
|
||||||
|
addCustomFilterModeItem();
|
||||||
|
}
|
||||||
|
|
||||||
modeComboBox->setToolTip(tr("How selected and unselected colors are combined in the filter"));
|
modeComboBox->setToolTip(tr("How selected and unselected colors are combined in the filter"));
|
||||||
|
modeComboBox->setAccessibleName(tr("Color filter mode"));
|
||||||
|
|
||||||
// Restore current mode
|
// Restore current mode
|
||||||
const int index = modeComboBox->findData(QVariant::fromValue(currentMode));
|
const int index = modeComboBox->findData(QVariant::fromValue(currentMode));
|
||||||
|
|
@ -74,6 +102,28 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi()
|
||||||
modeComboBox->blockSignals(false);
|
modeComboBox->blockSignals(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void VisualDatabaseDisplayColorFilterWidget::setFilterMode(FilterMode filterMode)
|
||||||
|
{
|
||||||
|
int index = modeComboBox->findData(QVariant::fromValue(filterMode));
|
||||||
|
|
||||||
|
if (index < 0 && filterMode == FilterMode::Custom) {
|
||||||
|
index = modeComboBox->count();
|
||||||
|
addCustomFilterModeItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
modeComboBox->setCurrentIndex(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VisualDatabaseDisplayColorFilterWidget::addCustomFilterModeItem()
|
||||||
|
{
|
||||||
|
int customIndex = modeComboBox->count();
|
||||||
|
modeComboBox->addItem(tr("Custom"), QVariant::fromValue(FilterMode::Custom));
|
||||||
|
modeComboBox->setItemData(customIndex, tr("Cards matching an unknown combination of color filters"),
|
||||||
|
Qt::ToolTipRole);
|
||||||
|
}
|
||||||
|
|
||||||
void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, bool active)
|
void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, bool active)
|
||||||
{
|
{
|
||||||
if (active) {
|
if (active) {
|
||||||
|
|
@ -85,74 +135,24 @@ void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, boo
|
||||||
|
|
||||||
void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color)
|
void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color)
|
||||||
{
|
{
|
||||||
QString colorString = color;
|
FilterModeInfo info = getFilterModeInfo(currentMode);
|
||||||
QString typeStr;
|
|
||||||
|
|
||||||
// Remove previous filters
|
removeFilters(filterModel, color, info.attr);
|
||||||
|
|
||||||
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
|
if (info.includeType != CardFilter::Type::TypeEnd) {
|
||||||
QList<const CardFilter *> matchingFilters;
|
filterModel->addFilter(new CardFilter(color, info.includeType, info.attr));
|
||||||
|
|
||||||
for (const CardFilter *filter : allColorFilters) {
|
|
||||||
if (filter->term() == color) {
|
|
||||||
matchingFilters.append(filter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const CardFilter *filter : matchingFilters) {
|
|
||||||
filterModel->removeFilter(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add actual filter
|
|
||||||
|
|
||||||
switch (currentMode) {
|
|
||||||
case FilterMode::ExactMatch:
|
|
||||||
filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeAnd, CardFilter::Attr::AttrColor));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case FilterMode::Includes:
|
|
||||||
filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case FilterMode::IncludeExclude:
|
|
||||||
filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor));
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color)
|
void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color)
|
||||||
{
|
{
|
||||||
QString colorString = color;
|
FilterModeInfo info = getFilterModeInfo(currentMode);
|
||||||
|
|
||||||
// Remove inclusion filters
|
removeFilters(filterModel, color, info.attr);
|
||||||
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
|
|
||||||
QList<const CardFilter *> matchingFilters;
|
|
||||||
|
|
||||||
for (const CardFilter *filter : allColorFilters) {
|
// Add exclusion filters if the mode demands it.
|
||||||
if (filter->term() == color) {
|
if (info.excludeType != CardFilter::Type::TypeEnd) {
|
||||||
matchingFilters.append(filter);
|
filterModel->addFilter(new CardFilter(color, info.excludeType, info.attr));
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const CardFilter *filter : matchingFilters) {
|
|
||||||
filterModel->removeFilter(filter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add exclusion filters if the mode demands it
|
|
||||||
switch (currentMode) {
|
|
||||||
case FilterMode::ExactMatch:
|
|
||||||
filterModel->addFilter(
|
|
||||||
new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case FilterMode::IncludeExclude:
|
|
||||||
filterModel->addFilter(
|
|
||||||
new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case FilterMode::Includes:
|
|
||||||
// No exclusion in Includes mode
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,6 +169,7 @@ void VisualDatabaseDisplayColorFilterWidget::updateFilterMode()
|
||||||
filterModel->filterTree()->blockSignals(true);
|
filterModel->filterTree()->blockSignals(true);
|
||||||
|
|
||||||
filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor);
|
filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor);
|
||||||
|
filterModel->clearFiltersOfType(CardFilter::Attr::AttrColorIdentity);
|
||||||
|
|
||||||
const QList<ManaSymbolWidget *> manaSymbolWidgets = findChildren<ManaSymbolWidget *>();
|
const QList<ManaSymbolWidget *> manaSymbolWidgets = findChildren<ManaSymbolWidget *>();
|
||||||
|
|
||||||
|
|
@ -211,52 +212,53 @@ QList<QChar> VisualDatabaseDisplayColorFilterWidget::getActiveColors()
|
||||||
|
|
||||||
void VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel()
|
void VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel()
|
||||||
{
|
{
|
||||||
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
|
QList<const CardFilter *> filters;
|
||||||
|
filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColor));
|
||||||
|
filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColorIdentity));
|
||||||
|
|
||||||
QList<ManaSymbolWidget *> manaSymbolWidgets = findChildren<ManaSymbolWidget *>();
|
// Re-derive the mode from the filters in the model, so that a loaded filter set selects the mode that
|
||||||
|
// produced it (or Custom when none could have).
|
||||||
|
currentMode = deriveFilterMode(filters, currentMode);
|
||||||
|
|
||||||
for (ManaSymbolWidget *manaSymbolWidget : manaSymbolWidgets) {
|
// Update the combobox. Add a Custom item only if we have a custom filter mode.
|
||||||
|
int customIndex = modeComboBox->findData(QVariant::fromValue(FilterMode::Custom));
|
||||||
|
modeComboBox->blockSignals(true);
|
||||||
|
if (currentMode == FilterMode::Custom) {
|
||||||
|
if (customIndex < 0) {
|
||||||
|
customIndex = modeComboBox->count();
|
||||||
|
addCustomFilterModeItem();
|
||||||
|
}
|
||||||
|
modeComboBox->setCurrentIndex(customIndex);
|
||||||
|
} else {
|
||||||
|
if (customIndex >= 0) {
|
||||||
|
modeComboBox->removeItem(customIndex);
|
||||||
|
}
|
||||||
|
int index = modeComboBox->findData(QVariant::fromValue(currentMode));
|
||||||
|
if (index >= 0) {
|
||||||
|
modeComboBox->setCurrentIndex(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modeComboBox->blockSignals(false);
|
||||||
|
|
||||||
|
// Update the mana symbol widgets by looking for the current mode's include or exclude filters.
|
||||||
|
const FilterModeInfo modeInfo = getFilterModeInfo(currentMode);
|
||||||
|
|
||||||
|
const bool useExclude =
|
||||||
|
modeInfo.includeType == CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd;
|
||||||
|
const CardFilter::Type filterType = useExclude ? modeInfo.excludeType : modeInfo.includeType;
|
||||||
|
|
||||||
|
filters = filterModel->getFiltersOfType(modeInfo.attr);
|
||||||
|
|
||||||
|
for (ManaSymbolWidget *manaSymbolWidget : findChildren<ManaSymbolWidget *>()) {
|
||||||
bool found = false;
|
bool found = false;
|
||||||
for (const CardFilter *filter : allColorFilters) {
|
for (const CardFilter *filter : filters) {
|
||||||
if (manaSymbolWidget->getSymbolChar() == filter->term()) {
|
if (filter->type() == filterType && manaSymbolWidget->getSymbolChar() == filter->term()) {
|
||||||
switch (currentMode) {
|
|
||||||
case FilterMode::ExactMatch:
|
|
||||||
switch (filter->type()) {
|
|
||||||
case CardFilter::Type::TypeAnd:
|
|
||||||
setManaSymbolActive(filter->term().at(0), true);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
setManaSymbolActive(filter->term().at(0), false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case FilterMode::Includes:
|
|
||||||
switch (filter->type()) {
|
|
||||||
case CardFilter::Type::TypeOr:
|
|
||||||
setManaSymbolActive(filter->term().at(0), true);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
setManaSymbolActive(filter->term().at(0), false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case FilterMode::IncludeExclude:
|
|
||||||
switch (filter->type()) {
|
|
||||||
case CardFilter::Type::TypeOr:
|
|
||||||
setManaSymbolActive(filter->term().at(0), true);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
setManaSymbolActive(filter->term().at(0), false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
found = true;
|
found = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!found) {
|
manaSymbolWidget->setColorActive(useExclude ? !found : found);
|
||||||
setManaSymbolActive(manaSymbolWidget->getSymbolChar(), false);
|
manaSymbolWidget->setDisabled(currentMode == FilterMode::Custom);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
#include <QChar>
|
#include <QChar>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QMetaType>
|
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
#include <libcockatrice/filters/filter_mode.h>
|
||||||
#include <libcockatrice/utility/debouncer.h>
|
#include <libcockatrice/utility/debouncer.h>
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
|
|
@ -38,15 +38,6 @@ private:
|
||||||
int circleDiameter;
|
int circleDiameter;
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class FilterMode
|
|
||||||
{
|
|
||||||
ExactMatch, // Only selected colors are included, all others are excluded.
|
|
||||||
Includes, // Include selected colors (OR condition).
|
|
||||||
IncludeExclude // Include selected colors (OR) and exclude unselected colors (AND NOT).
|
|
||||||
};
|
|
||||||
|
|
||||||
Q_DECLARE_METATYPE(FilterMode)
|
|
||||||
|
|
||||||
class VisualDatabaseDisplayColorFilterWidget : public QWidget
|
class VisualDatabaseDisplayColorFilterWidget : public QWidget
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
@ -54,11 +45,13 @@ class VisualDatabaseDisplayColorFilterWidget : public QWidget
|
||||||
public:
|
public:
|
||||||
explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel);
|
explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel);
|
||||||
void retranslateUi();
|
void retranslateUi();
|
||||||
|
void setFilterMode(FilterMode filterMode);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void filterModeChanged(FilterMode filterMode);
|
void filterModeChanged(FilterMode filterMode);
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
|
void addCustomFilterModeItem();
|
||||||
void handleColorToggled(QChar color, bool active);
|
void handleColorToggled(QChar color, bool active);
|
||||||
void addFilter(QChar color);
|
void addFilter(QChar color);
|
||||||
void removeFilter(QChar color);
|
void removeFilter(QChar color);
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
|
||||||
connect(clearFilterWidget, &QToolButton::clicked, this, [this] {
|
connect(clearFilterWidget, &QToolButton::clicked, this, [this] {
|
||||||
filterModel->blockSignals(true);
|
filterModel->blockSignals(true);
|
||||||
filterModel->filterTree()->blockSignals(true);
|
filterModel->filterTree()->blockSignals(true);
|
||||||
|
colorFilterWidget->setFilterMode(FilterMode::Includes);
|
||||||
filterModel->clear();
|
filterModel->clear();
|
||||||
filterModel->blockSignals(false);
|
filterModel->blockSignals(false);
|
||||||
filterModel->filterTree()->blockSignals(false);
|
filterModel->filterTree()->blockSignals(false);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
#include <QElapsedTimer>
|
#include <QElapsedTimer>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QFutureWatcher>
|
#include <QFuture>
|
||||||
#include <QMetaObject>
|
#include <QMetaObject>
|
||||||
#include <QMetaType>
|
#include <QMetaType>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
|
|
@ -244,15 +244,15 @@ void VisualDeckStorageModel::startScan()
|
||||||
|
|
||||||
// The scan (directory walk + one stat per file) runs on a worker thread so that
|
// The scan (directory walk + one stat per file) runs on a worker thread so that
|
||||||
// constructing the widget never stalls the UI thread on a large deck folder.
|
// constructing the widget never stalls the UI thread on a large deck folder.
|
||||||
auto *watcher = new QFutureWatcher<DeckScanResult>(this);
|
// The continuation is bound to this model as its context object: it runs on the UI thread,
|
||||||
connect(watcher, &QFutureWatcher<DeckScanResult>::finished, this, [this, watcher, generation] {
|
// and Qt drops it if the model is destroyed first, so no result is delivered to a dead model.
|
||||||
watcher->deleteLater();
|
QFuture<DeckScanResult> scan =
|
||||||
|
QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); });
|
||||||
|
scan.then(this, [this, generation](const DeckScanResult &result) {
|
||||||
if (generation != scanGeneration) {
|
if (generation != scanGeneration) {
|
||||||
return; // A newer scan started while this one was running; drop the stale result.
|
return; // A newer scan started while this one was running; drop the stale result.
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeckScanResult result = watcher->result();
|
|
||||||
folderPaths = result.folderPaths;
|
folderPaths = result.folderPaths;
|
||||||
|
|
||||||
if (result.decks.isEmpty()) {
|
if (result.decks.isEmpty()) {
|
||||||
|
|
@ -268,9 +268,6 @@ void VisualDeckStorageModel::startScan()
|
||||||
beginLoad(row);
|
beginLoad(row);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watcher->setFuture(
|
|
||||||
QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void VisualDeckStorageModel::beginLoad(int row)
|
void VisualDeckStorageModel::beginLoad(int row)
|
||||||
|
|
@ -286,32 +283,7 @@ void VisualDeckStorageModel::beginLoad(int row)
|
||||||
const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath);
|
const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath);
|
||||||
const int generation = scanGeneration;
|
const int generation = scanGeneration;
|
||||||
|
|
||||||
auto *watcher = new QFutureWatcher<std::optional<DeckLoadResult>>(this);
|
auto load = QtConcurrent::run([filePath, fmt]() -> std::optional<DeckLoadResult> {
|
||||||
connect(watcher, &QFutureWatcher<std::optional<DeckLoadResult>>::finished, this,
|
|
||||||
[this, watcher, filePath, generation] {
|
|
||||||
watcher->deleteLater();
|
|
||||||
|
|
||||||
if (generation != scanGeneration) {
|
|
||||||
return; // The deck list was re-scanned while this load was running; drop the stale result.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Queue the result and apply a bounded number per event loop turn so that
|
|
||||||
// finishing hundreds of loads at once cannot stall the UI thread.
|
|
||||||
const std::optional<DeckLoadResult> result = watcher->result();
|
|
||||||
PendingDeckLoad pending;
|
|
||||||
pending.filePath = filePath;
|
|
||||||
pending.generation = generation;
|
|
||||||
if (result) {
|
|
||||||
pending.ok = true;
|
|
||||||
pending.deck = std::move(result->deck);
|
|
||||||
pending.lastModified = result->lastModified;
|
|
||||||
pending.colorIdentity = std::move(result->colorIdentity);
|
|
||||||
}
|
|
||||||
pendingLoads.append(std::move(pending));
|
|
||||||
schedulePendingLoadDrain();
|
|
||||||
});
|
|
||||||
|
|
||||||
watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional<DeckLoadResult> {
|
|
||||||
std::optional<LoadedDeck> deck = DeckLoader::loadFromFile(filePath, fmt, false);
|
std::optional<LoadedDeck> deck = DeckLoader::loadFromFile(filePath, fmt, false);
|
||||||
if (!deck) {
|
if (!deck) {
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
|
|
@ -320,7 +292,28 @@ void VisualDeckStorageModel::beginLoad(int row)
|
||||||
// keep the completion handler on the UI thread cheap.
|
// keep the completion handler on the UI thread cheap.
|
||||||
const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query());
|
const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query());
|
||||||
return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity};
|
return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity};
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
// Bound to this model as context: runs on the UI thread and is dropped if the model is destroyed first.
|
||||||
|
load.then(this, [this, filePath, generation](std::optional<DeckLoadResult> result) {
|
||||||
|
if (generation != scanGeneration) {
|
||||||
|
return; // The deck list was re-scanned while this load was running; drop the stale result.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue the result and apply a bounded number per event loop turn so that
|
||||||
|
// finishing hundreds of loads at once cannot stall the UI thread.
|
||||||
|
PendingDeckLoad pending;
|
||||||
|
pending.filePath = filePath;
|
||||||
|
pending.generation = generation;
|
||||||
|
if (result) {
|
||||||
|
pending.ok = true;
|
||||||
|
pending.deck = std::move(result->deck);
|
||||||
|
pending.lastModified = result->lastModified;
|
||||||
|
pending.colorIdentity = std::move(result->colorIdentity);
|
||||||
|
}
|
||||||
|
pendingLoads.append(std::move(pending));
|
||||||
|
schedulePendingLoadDrain();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void VisualDeckStorageModel::schedulePendingLoadDrain()
|
void VisualDeckStorageModel::schedulePendingLoadDrain()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
## Building Doxygen Documentation
|
## Building Doxygen Documentation
|
||||||
|
|
||||||
We use [Doxygen](https://www.doxygen.nl) for our code documentation.
|
We use [Doxygen](https://www.doxygen.nl) for our code documentation.
|
||||||
After installing the tool, you can build a HTML webpage into the `/docs` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile):
|
After installing the tool, you can build a HTML webpage into the `/docs-output` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile):
|
||||||
```bash
|
```bash
|
||||||
doxygen Doxyfile
|
doxygen Doxyfile
|
||||||
```
|
```
|
||||||
Open the `index.html` file in the `/docs/html/` folder to browse the result locally.
|
Open the `index.html` file in the `/docs-output/html/` folder to browse the result locally.
|
||||||
There is also an online version we host at the Cockatrice webpage: [cockatrice.github.io/docs](https://cockatrice.github.io/docs)
|
There is also an online version we host at the Cockatrice webpage: [cockatrice.github.io/docs](https://cockatrice.github.io/docs)
|
||||||
|
|
||||||
Doxygen's own documenation can be found at [www.doxygen.nl/manual](https://www.doxygen.nl/manual).
|
Doxygen's own documenation can be found at [www.doxygen.nl/manual](https://www.doxygen.nl/manual).
|
||||||
|
|
@ -25,4 +25,4 @@ FULL_SIDEBAR = NO # Required for doxygen-awesome
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
Cockatrice dedicated color adjustments are configured in the [cockatrice_docs_style.css](https://github.com/Cockatrice/Cockatrice/blob/master/doc/doxygen/css/cockatrice_docs_style.css) file that uses & overrides the theme definitions.
|
Cockatrice dedicated color adjustments are configured in the [cockatrice_docs_style.css](https://github.com/Cockatrice/Cockatrice/blob/master/docs/doxygen/css/cockatrice_docs_style.css) file that uses & overrides the theme definitions.
|
||||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 398 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 885 B After Width: | Height: | Size: 885 B |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |