mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-10 20:13:24 -08:00
Merge upstream/master into fix/picture-loader-cache-offline
Keep the decoder-aware Accept header from this branch; upstream #6609 only fixed the wildcard spelling of the previous hardcoded header.
This commit is contained in:
commit
00ce5e9ce3
207 changed files with 5681 additions and 1240 deletions
|
|
@ -1,9 +1,9 @@
|
|||
from archlinux:latest
|
||||
FROM archlinux:latest
|
||||
|
||||
RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \
|
||||
base-devel \
|
||||
ccache \
|
||||
cmake \
|
||||
gcc \
|
||||
git \
|
||||
gtest \
|
||||
mariadb-libs \
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
FROM debian:12
|
||||
FROM debian:12-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
ccache \
|
||||
cmake \
|
||||
dpkg-dev \
|
||||
file \
|
||||
g++ \
|
||||
git \
|
||||
libcurl4-openssl-dev \
|
||||
libgl-dev \
|
||||
libgtest-dev \
|
||||
liblzma-dev \
|
||||
libmariadb-dev-compat \
|
||||
libprotobuf-dev \
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
FROM debian:13
|
||||
FROM debian:13-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
ccache \
|
||||
cmake \
|
||||
dpkg-dev \
|
||||
file \
|
||||
g++ \
|
||||
git \
|
||||
libcurl4-openssl-dev \
|
||||
libgl-dev \
|
||||
libgtest-dev \
|
||||
liblzma-dev \
|
||||
libmariadb-dev-compat \
|
||||
libprotobuf-dev \
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
FROM fedora:44
|
||||
|
||||
RUN dnf install -y \
|
||||
RUN dnf install -y --nodocs \
|
||||
ca-certificates \
|
||||
ccache \
|
||||
cmake \
|
||||
gcc-c++ \
|
||||
git \
|
||||
gtest-devel \
|
||||
libcurl-devel \
|
||||
mariadb-devel \
|
||||
ninja-build \
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
FROM fedora:43
|
||||
FROM fedora:45
|
||||
|
||||
RUN dnf install -y \
|
||||
RUN dnf install -y --nodocs \
|
||||
ca-certificates \
|
||||
ccache \
|
||||
cmake \
|
||||
gcc-c++ \
|
||||
git \
|
||||
gtest-devel \
|
||||
libcurl-devel \
|
||||
mariadb-devel \
|
||||
ninja-build \
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
FROM debian:12
|
||||
FROM debian:12-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ccache \
|
||||
cmake \
|
||||
dpkg-dev \
|
||||
file \
|
||||
g++ \
|
||||
git \
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@ FROM ubuntu:24.04
|
|||
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
ccache \
|
||||
cmake \
|
||||
dpkg-dev \
|
||||
file \
|
||||
g++ \
|
||||
git \
|
||||
libcurl4-openssl-dev \
|
||||
libgl-dev \
|
||||
libgtest-dev \
|
||||
liblzma-dev \
|
||||
libmariadb-dev-compat \
|
||||
libprotobuf-dev \
|
||||
|
|
|
|||
|
|
@ -2,16 +2,17 @@ FROM ubuntu:26.04
|
|||
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
ccache \
|
||||
cmake \
|
||||
dpkg-dev \
|
||||
file \
|
||||
g++ \
|
||||
gcovr \
|
||||
git \
|
||||
libcurl4-openssl-dev \
|
||||
libgl-dev \
|
||||
libgtest-dev \
|
||||
liblzma-dev \
|
||||
libmariadb-dev-compat \
|
||||
libprotobuf-dev \
|
||||
|
|
|
|||
234
.ci/compile.sh
234
.ci/compile.sh
|
|
@ -20,6 +20,7 @@
|
|||
# --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
|
||||
# (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
|
||||
|
||||
# Read arguments
|
||||
|
|
@ -138,17 +139,21 @@ done
|
|||
|
||||
set -e
|
||||
|
||||
# Setup
|
||||
./servatrice/check_schema_version.sh
|
||||
if [[ ! $BUILDTYPE ]]; then
|
||||
# Schema version consistency (only relevant to the server)
|
||||
if [[ ${MAKE_SERVER:-} == "1" ]]; then
|
||||
./servatrice/check_schema_version.sh
|
||||
fi
|
||||
|
||||
# Use default values if unset
|
||||
if [[ -z $BUILDTYPE ]]; then
|
||||
BUILDTYPE=Release
|
||||
fi
|
||||
if [[ ! $BUILD_DIR ]]; then
|
||||
if [[ -z $BUILD_DIR ]]; then
|
||||
BUILD_DIR="build"
|
||||
fi
|
||||
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
|
||||
# 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
|
||||
|
|
@ -163,16 +168,16 @@ export CMAKE_POLICY_VERSION_MINIMUM=3.10
|
|||
|
||||
# Add cmake flags
|
||||
flags=("-DCMAKE_BUILD_TYPE=$BUILDTYPE")
|
||||
if [[ $MAKE_SERVER ]]; then
|
||||
if [[ ${MAKE_SERVER:-} == "1" ]]; then
|
||||
flags+=("-DWITH_SERVER=1")
|
||||
fi
|
||||
if [[ $MAKE_NO_CLIENT ]]; then
|
||||
if [[ ${MAKE_NO_CLIENT:-} == "1" ]]; then
|
||||
flags+=("-DWITH_CLIENT=0" "-DWITH_ORACLE=0")
|
||||
fi
|
||||
if [[ $MAKE_TEST ]]; then
|
||||
if [[ ${MAKE_TEST:-} == "1" ]]; then
|
||||
flags+=("-DTEST=1")
|
||||
fi
|
||||
if [[ $MAKE_SANITIZE ]]; then
|
||||
if [[ ${MAKE_SANITIZE:-} == "1" ]]; then
|
||||
flags+=("-DENABLE_SANITIZERS=ON")
|
||||
else
|
||||
# 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.
|
||||
flags+=("-DENABLE_SANITIZERS=OFF")
|
||||
fi
|
||||
if [[ $MAKE_COVERAGE ]]; then
|
||||
if [[ ${MAKE_COVERAGE:-} == "1" ]]; then
|
||||
flags+=("-DENABLE_COVERAGE=ON")
|
||||
fi
|
||||
if [[ $USE_CCACHE ]]; then
|
||||
if [[ ${USE_CCACHE:-} == "1" ]]; then
|
||||
flags+=("-DUSE_CCACHE=1")
|
||||
if [[ $CCACHE_SIZE ]]; then
|
||||
# note, this setting persists after running the script
|
||||
if [[ -n $CCACHE_SIZE ]]; then
|
||||
# This setting persists after running the script
|
||||
ccache --max-size "$CCACHE_SIZE"
|
||||
fi
|
||||
fi
|
||||
if [[ $PACKAGE_TYPE ]]; then
|
||||
if [[ -n $PACKAGE_TYPE ]]; then
|
||||
flags+=("-DCPACK_GENERATOR=$PACKAGE_TYPE")
|
||||
fi
|
||||
if [[ $USE_VCPKG ]]; then
|
||||
if [[ ${USE_VCPKG:-} == "1" ]]; then
|
||||
flags+=("-DUSE_VCPKG=1")
|
||||
flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions")
|
||||
fi
|
||||
|
|
@ -203,6 +208,11 @@ if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then
|
|||
# developer builds never reach Sentry.
|
||||
flags+=("-DWITH_CRASH_REPORTING=ON")
|
||||
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
|
||||
# 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
|
||||
|
|
@ -216,7 +226,8 @@ fi
|
|||
buildflags=(--config "$BUILDTYPE")
|
||||
|
||||
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
|
||||
if got="$(ccache --show-stats --verbose 2>/dev/null)"; then
|
||||
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
|
||||
export QTDIR
|
||||
|
||||
if [[ $TARGET_MACOS_VERSION ]]; then
|
||||
# CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla cmake flag needed to compile to target macOS version
|
||||
if [[ -n $TARGET_MACOS_VERSION ]]; then
|
||||
# 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")
|
||||
|
||||
# vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version
|
||||
# an easy way is to copy the x64-osx.cmake file and modify it
|
||||
triplets_dir="/tmp/cmake/triplets"
|
||||
triplet_version="custom-triplet"
|
||||
triplet_file="$triplets_dir/$triplet_version.cmake"
|
||||
arch=$(uname -m)
|
||||
if [[ $arch == x86_64 ]]; then
|
||||
arch="x64"
|
||||
if [[ ${USE_VCPKG:-} == "1" ]]; then
|
||||
# vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version
|
||||
# An easy way is to copy the x64-osx.cmake file and modify it
|
||||
triplets_dir="/tmp/cmake/triplets"
|
||||
triplet_version="custom-triplet"
|
||||
triplet_file="$triplets_dir/$triplet_version.cmake"
|
||||
arch=$(uname -m)
|
||||
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
|
||||
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
|
||||
|
||||
echo "::group::Signing Certificate"
|
||||
echo "::group::Setup signing certificate"
|
||||
if [[ -n "$MACOS_CERTIFICATE_NAME" ]]; then
|
||||
echo "$MACOS_CERTIFICATE" | base64 --decode >"certificate.p12"
|
||||
security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain
|
||||
|
|
@ -306,9 +319,13 @@ if [[ $RUNNER_OS == macOS ]]; then
|
|||
fi
|
||||
echo "::endgroup::"
|
||||
|
||||
if [[ $MAKE_PACKAGE ]]; then
|
||||
# Workaround https://github.com/actions/runner-images/issues/7522
|
||||
# have hdiutil repeat the command 10 times in hope of success.
|
||||
if [[ ${MAKE_PACKAGE:-} == "1" ]]; then
|
||||
# Workaround https://github.com/actions/runner-images/issues/7522: XProtect holds a
|
||||
# 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
|
||||
# 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
|
||||
|
|
@ -317,17 +334,74 @@ if [[ $RUNNER_OS == macOS ]]; then
|
|||
# shellcheck disable=SC2016
|
||||
echo '#!/bin/bash
|
||||
attempts=10
|
||||
i=0
|
||||
while true; do
|
||||
hdiutil "$@" && exit 0
|
||||
rc=$?
|
||||
if (( ++i >= attempts )); then
|
||||
echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2
|
||||
exit "$rc"
|
||||
timeout_seconds=${HDIUTIL_TIMEOUT:-300}
|
||||
subcommand=$1
|
||||
# CPACK_DMG_VOLUME_NAME in CMakeLists.txt; a -volname argument wins when there is one.
|
||||
volume_name=Cockatrice
|
||||
arg_count=$#
|
||||
for (( i = 1; i < arg_count; i++ )); do
|
||||
if [[ ${!i} == -volname ]] && (( i + 1 <= arg_count )); then
|
||||
(( i++ ))
|
||||
volume_name=${!i}
|
||||
fi
|
||||
echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2
|
||||
sleep 1
|
||||
done' >"$hdiutil_script"
|
||||
done
|
||||
|
||||
# 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"
|
||||
flags+=(-DCPACK_COMMAND_HDIUTIL="$hdiutil_script")
|
||||
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
|
||||
# MSBuild builds a single node: one project at a time, whatever MTT does inside
|
||||
# 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}"
|
||||
fi
|
||||
buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL")
|
||||
|
|
@ -363,19 +437,20 @@ elif [[ $RUNNER_OS == Windows ]]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
if [[ $USE_CCACHE ]]; then
|
||||
if [[ ${USE_CCACHE:-} == "1" ]]; then
|
||||
echo "::group::Show ccache stats"
|
||||
ccachestatsverbose
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
|
||||
echo "::group::Configure cmake"
|
||||
# Configure CMake
|
||||
echo "::group::Generate build system"
|
||||
cmake --version
|
||||
echo "Running cmake with flags: ${flags[*]}"
|
||||
echo "Running CMake configuration with the following flags: ${flags[*]}"
|
||||
cmake .. "${flags[@]}"
|
||||
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
|
||||
# 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
|
||||
|
|
@ -390,9 +465,10 @@ if [[ $USE_CCACHE ]]; then
|
|||
ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime
|
||||
fi
|
||||
|
||||
# Build
|
||||
echo "::group::Build project"
|
||||
cpuinfo
|
||||
echo "Running cmake --build with flags: ${buildflags[*]}"
|
||||
echo "Running CMake with the following build flags: ${buildflags[*]}"
|
||||
cmake --build . "${buildflags[@]}"
|
||||
echo "::endgroup::"
|
||||
|
||||
|
|
@ -404,16 +480,16 @@ if [[ $BUILD_PROFILE && $RUNNER_OS == Windows && -f msbuild-perf.log ]]; then
|
|||
echo "::endgroup::"
|
||||
fi
|
||||
|
||||
if [[ $USE_CCACHE ]]; then
|
||||
if [[ $CCACHE_EVICTION_AGE ]]; then
|
||||
echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE"
|
||||
if [[ ${USE_CCACHE:-} == "1" ]]; then
|
||||
if [[ -n $CCACHE_EVICTION_AGE ]]; then
|
||||
echo "::group::Evict ccache files older than $CCACHE_EVICTION_AGE"
|
||||
ccache --evict-older-than "$CCACHE_EVICTION_AGE"
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
echo "::group::Show ccache stats again"
|
||||
ccachestatsverbose
|
||||
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!"
|
||||
fi
|
||||
|
||||
|
|
@ -430,9 +506,9 @@ if [[ $RUNNER_OS == macOS ]]; then
|
|||
echo "::endgroup::"
|
||||
fi
|
||||
|
||||
if [[ $MAKE_TEST ]]; then
|
||||
if [[ ${MAKE_TEST:-} == "1" ]]; then
|
||||
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
|
||||
# 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
|
||||
|
|
@ -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
|
||||
# run concurrently: the only one that claims a fixed resource is single_instance_manager_test,
|
||||
# which already scopes its local socket name to its own pid so parallel copies cannot collide.
|
||||
ctest --version
|
||||
ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}"
|
||||
echo "::endgroup::"
|
||||
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
|
||||
# and skipped rather than failing an otherwise green build.
|
||||
echo "::group::Coverage report"
|
||||
|
|
@ -479,7 +556,7 @@ if [[ $MAKE_COVERAGE ]]; then
|
|||
echo "::endgroup::"
|
||||
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"
|
||||
# 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
|
||||
|
|
@ -679,18 +756,21 @@ POWERSHELL
|
|||
echo "::endgroup::"
|
||||
fi
|
||||
|
||||
if [[ $MAKE_INSTALL ]]; then
|
||||
# Install
|
||||
if [[ ${MAKE_INSTALL:-} == "1" ]]; then
|
||||
echo "::group::Install"
|
||||
cmake --build . --target install --config "$BUILDTYPE"
|
||||
echo "::endgroup::"
|
||||
fi
|
||||
|
||||
if [[ $MAKE_PACKAGE ]]; then
|
||||
# Package
|
||||
if [[ ${MAKE_PACKAGE:-} == "1" ]]; then
|
||||
echo "::group::Create package"
|
||||
cpack --version
|
||||
cmake --build . --target package --config "$BUILDTYPE"
|
||||
echo "::endgroup::"
|
||||
|
||||
if [[ $PACKAGE_SUFFIX ]]; then
|
||||
if [[ -n $PACKAGE_SUFFIX ]]; then
|
||||
echo "::group::Update package name"
|
||||
cd ..
|
||||
BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX"
|
||||
|
|
|
|||
|
|
@ -181,6 +181,11 @@ function RUN ()
|
|||
if [[ ${WITH_CRASH_REPORTING:-} ]]; then
|
||||
args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}")
|
||||
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
|
||||
docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@"
|
||||
return $?
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ Available pre-compiled binaries for installation:
|
|||
• <kbd>Ubuntu 24.04 LTS</kbd> <sub><i>Noble Numbat</i></sub>
|
||||
• <kbd>Debian 13</kbd> <sub><i>Trixie</i></sub>
|
||||
• <kbd>Debian 12</kbd> <sub><i>Bookworm</i></sub>
|
||||
• <kbd>Fedora 45</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>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/
|
||||
.tx/
|
||||
cockatrice/
|
||||
doc/
|
||||
docs/
|
||||
oracle/
|
||||
Dockerfile
|
||||
|
|
|
|||
13
.github/dependabot.yml
vendored
13
.github/dependabot.yml
vendored
|
|
@ -15,8 +15,11 @@ updates:
|
|||
# Check for updates once a month
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
# A new version is not considered for a version update until x days after its release (default = 3)
|
||||
cooldown:
|
||||
default-days: 3
|
||||
# Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted)
|
||||
open-pull-requests-limit: 2
|
||||
open-pull-requests-limit: 3
|
||||
|
||||
# # Enable version updates for Docker
|
||||
# Not yet possible to bump from one LTS version to the next and skip others, see:
|
||||
|
|
@ -27,6 +30,9 @@ updates:
|
|||
# # Check for updates once a week
|
||||
# schedule:
|
||||
# interval: "weekly"
|
||||
# # A new version is not considered for a version update until x days after its release (default = 3)
|
||||
# cooldown:
|
||||
# default-days: 3
|
||||
# # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted)
|
||||
# open-pull-requests-limit: 1
|
||||
|
||||
|
|
@ -37,5 +43,8 @@ updates:
|
|||
# Check for updates to GitHub Actions once a week
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
# A new version is not considered for a version update until x days after its release (default = 3)
|
||||
cooldown:
|
||||
default-days: 3
|
||||
# Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted)
|
||||
open-pull-requests-limit: 2
|
||||
open-pull-requests-limit: 5
|
||||
|
|
|
|||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
|
|
@ -40,10 +40,10 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Checkout repository"
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: "Initialize CodeQL"
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
|
@ -92,6 +92,6 @@ jobs:
|
|||
run: cmake --build build
|
||||
|
||||
- name: "Perform CodeQL Analysis"
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
|
|
|||
403
.github/workflows/desktop-build.yml
vendored
403
.github/workflows/desktop-build.yml
vendored
|
|
@ -15,7 +15,7 @@ on:
|
|||
- '!**.md'
|
||||
- '!.github/**'
|
||||
- '!.tx/**'
|
||||
- '!doc/**'
|
||||
- '!docs/**'
|
||||
- '.github/workflows/desktop-build.yml'
|
||||
- 'CMakeLists.txt'
|
||||
- 'vcpkg.json'
|
||||
|
|
@ -28,7 +28,7 @@ on:
|
|||
- '!**.md'
|
||||
- '!.github/**'
|
||||
- '!.tx/**'
|
||||
- '!doc/**'
|
||||
- '!docs/**'
|
||||
- '.github/workflows/desktop-build.yml'
|
||||
- 'CMakeLists.txt'
|
||||
- 'vcpkg.json'
|
||||
|
|
@ -47,6 +47,20 @@ concurrency:
|
|||
group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})"
|
||||
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:
|
||||
configure:
|
||||
name: Configure
|
||||
|
|
@ -77,7 +91,7 @@ jobs:
|
|||
|
||||
- name: "Checkout"
|
||||
if: steps.configure.outputs.tag != null
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0 # fetch all history for all branches and tags
|
||||
|
||||
|
|
@ -89,7 +103,7 @@ jobs:
|
|||
TAG: ${{ steps.configure.outputs.tag }}
|
||||
run: .ci/prep_release.sh
|
||||
|
||||
- name: "Create release"
|
||||
- name: "Create or update draft release"
|
||||
if: steps.configure.outputs.tag != null
|
||||
id: create_release
|
||||
shell: bash
|
||||
|
|
@ -101,20 +115,42 @@ jobs:
|
|||
body_path: ${{ steps.prepare.outputs.body_path }}
|
||||
prerelease: ${{ steps.prepare.outputs.is_beta }}
|
||||
run: |
|
||||
args=()
|
||||
[[ $prerelease == yes ]] && args+=(--prerelease)
|
||||
# A release for this tag may already exist: a maintainer can save a draft in
|
||||
# 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[@]}" \
|
||||
--target "$target" \
|
||||
--title "$release_name" \
|
||||
--notes-file "$body_path"
|
||||
if [[ -z "$release_state" ]]; then
|
||||
args=()
|
||||
[[ $prerelease == yes ]] && args+=(--prerelease)
|
||||
|
||||
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:
|
||||
name: vcpkg baseline
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# vcpkg-tool reads versions/baseline.json out of the commit named by
|
||||
# `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule
|
||||
|
|
@ -149,7 +185,8 @@ jobs:
|
|||
|
||||
allow-failure: yes
|
||||
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
|
||||
version: 12
|
||||
|
|
@ -157,46 +194,47 @@ jobs:
|
|||
package: DEB
|
||||
server_only: yes
|
||||
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
|
||||
version: 13
|
||||
|
||||
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
|
||||
version: 44
|
||||
version: 45
|
||||
variant: release # separate image and ccache from the debug leg above
|
||||
|
||||
package: RPM
|
||||
# Arch and Fedora 44 are the two newest toolchains, and the -Wno-error= exceptions
|
||||
# in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug:
|
||||
# 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
|
||||
test: skip # The debug leg above runs this distro's suite
|
||||
ccache_size: 800M # Release plus the package: 614 MB of objects at -g1
|
||||
|
||||
- distro: Ubuntu
|
||||
version: 26.04
|
||||
|
||||
package: DEB
|
||||
test: skip # The sanitizer leg below runs this distro's suite
|
||||
|
||||
# The one leg that runs the suite under AddressSanitizer and
|
||||
# UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the
|
||||
# uninstrumented suite, so nothing is lost by instrumenting this one.
|
||||
#
|
||||
# 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
|
||||
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
|
||||
# matrix runs this distro's instrumented suite.
|
||||
persist_ccache: 'false'
|
||||
|
||||
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
|
||||
needs: configure
|
||||
|
|
@ -211,25 +249,71 @@ jobs:
|
|||
CCACHE_EVICTION_AGE: 7d
|
||||
# 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),
|
||||
# not against what one leg would like. The quota currently holds about 1.1 GB of
|
||||
# CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers
|
||||
# (mode=min in docker-release.yml, added here, brings the last one down to roughly
|
||||
# 0.3 GB - the one input here that is an expectation rather than a measurement, since
|
||||
# mode=min still writes one layer per pushed binary) plus the compiler caches of the nine legs a pull request runs. The five
|
||||
# nightly-only legs keep no compiler cache (see PERSIST_CCACHE below), which is what
|
||||
# makes these per-leg bounds affordable. Accounting for all of it: 6.45G of ccache
|
||||
# bounds + 3.2G of the fixed entries = 9.65G against the 10G quota.
|
||||
# not against what one leg would like. Measured 2026-11-07, the quota holds 2.97 GB
|
||||
# of these four ccache entries, 0.92 GB of Qt, 1.24 GB of vcpkg, 0.91 GB of buildkit
|
||||
# layers (mode=min in docker-release.yml) and 4.26 GB of CodeQL trap caches: one entry
|
||||
# per analyzed commit, about 1.07 GB each, of which every one but the newest is dead.
|
||||
# That is 10.30 GB against the 10 GB quota, so every save past this point costs some
|
||||
# other entry, and least-recently-used order reaches the buildkit layers before the
|
||||
# trap caches. prune-caches below keeps only the newest trap cache; the live total is
|
||||
# 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
|
||||
# below what a leg writes: ccache spent each build deleting the entries the next run
|
||||
# asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each
|
||||
# leg states the size it needs instead: one configuration for most, two for Fedora 44
|
||||
# (Debug for the suite, Release for the package), the server alone for Servatrice.
|
||||
# Raising these further means caching fewer legs, since GitHub evicts a whole entry
|
||||
# when the total is exceeded.
|
||||
# Measured 2026-11-04: one configuration costs 1.2 GB with full DWARF and 660 MB with
|
||||
# -g1 (COCKATRICE_DEBUG_INFO_LEVEL below), and the size of the cache decides the hit
|
||||
# rate far more sharply than its fill suggests. A 6G cache served 1059 of 1059 calls;
|
||||
# the 700M bound on the same leg served 14%, because ccache fills to the limit and then
|
||||
# evicts the entries the next build is about to ask for. A cache that cannot hold a
|
||||
# whole configuration therefore buys almost nothing, and the quota goes to fewer legs
|
||||
# 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.
|
||||
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'
|
||||
NAME: ${{ matrix.distro }}${{ matrix.version }}
|
||||
IMAGE_SUFFIX: ${{ matrix.variant }}
|
||||
|
|
@ -242,21 +326,23 @@ jobs:
|
|||
# also package, so testing and packaging cost one compile instead of two.
|
||||
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') }}
|
||||
# Whether this leg restores and saves its compiler cache. Every leg a pull request
|
||||
# runs keeps one; the nightly-only legs do not, so that the quota is spent on the
|
||||
# builds that review a change. A tag build runs both sets, and the nightly legs are
|
||||
# cold there - accepted, because a tag is rare and only the pull-request legs gate.
|
||||
PERSIST_CCACHE: 'true'
|
||||
# Whether this leg restores and saves its compiler cache. Not every pull-request leg
|
||||
# keeps one: a cache only pays for itself when its bound holds a whole configuration
|
||||
# (see the sizing comment above), and a leg that duplicates another leg's coverage
|
||||
# spends quota without adding any. The nightly-only legs keep none either. Fedora 45
|
||||
# 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.
|
||||
steps: &linux-steps
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: "Restore compiler cache (ccache)"
|
||||
id: restore_ccache
|
||||
if: env.PERSIST_CCACHE == 'true'
|
||||
uses: actions/cache/restore@v6
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }}
|
||||
path: ${{ env.CACHE }}/.ccache
|
||||
|
|
@ -303,7 +389,7 @@ jobs:
|
|||
|
||||
- name: "Upload coverage artifact"
|
||||
if: matrix.coverage == 'yes'
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
|
|
@ -399,6 +485,14 @@ jobs:
|
|||
# comes from the client event itself (COCKATRICE_SENTRY_RELEASE).
|
||||
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
|
||||
- name: "Delete remote compiler cache (ccache)"
|
||||
if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' && steps.restore_ccache.outputs.cache-hit
|
||||
|
|
@ -413,7 +507,7 @@ jobs:
|
|||
|
||||
- name: "Save updated compiler cache (ccache)"
|
||||
if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true'
|
||||
uses: actions/cache/save@v6
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
|
||||
path: ${{ env.CACHE }}/.ccache
|
||||
|
|
@ -421,7 +515,7 @@ jobs:
|
|||
- name: "Upload artifact"
|
||||
id: upload_artifact
|
||||
if: matrix.package != 'skip'
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
|
|
@ -441,7 +535,7 @@ jobs:
|
|||
- name: "Attest binary provenance"
|
||||
id: attestation
|
||||
if: steps.upload_release.outcome == 'success'
|
||||
uses: actions/attest@v4
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
show-summary: false
|
||||
subject-path: ${{ steps.build.outputs.path }}
|
||||
|
|
@ -466,7 +560,7 @@ jobs:
|
|||
test: skip # Running tests on all distros is superfluous
|
||||
|
||||
- distro: Fedora
|
||||
version: 43 # previous release, still covered by the nightly
|
||||
version: 44 # previous release, still covered by the nightly
|
||||
|
||||
package: RPM
|
||||
test: skip # Running tests on all distros is superfluous
|
||||
|
|
@ -477,6 +571,28 @@ jobs:
|
|||
package: DEB
|
||||
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
|
||||
# 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
|
||||
|
|
@ -501,7 +617,7 @@ jobs:
|
|||
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
|
||||
needs: configure
|
||||
# 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'
|
||||
runs-on: ubuntu-latest # https://github.com/actions/runner-images
|
||||
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
|
||||
# build costs a review. CCACHE_SIZE is still passed so ccache trims its scratch dir.
|
||||
PERSIST_CCACHE: 'false'
|
||||
CCACHE_SIZE: 600M
|
||||
CCACHE_SIZE: ${{ matrix.ccache_size || '600M' }}
|
||||
CMAKE_GENERATOR: 'Ninja'
|
||||
NAME: ${{ matrix.distro }}${{ matrix.version }}
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
ccache_eviction_age: 7d
|
||||
ccache_size: 700M # Release + package: ~0.4 GB at level 0
|
||||
cmake_generator: Ninja
|
||||
make_package: 1
|
||||
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
|
||||
|
||||
ccache_eviction_age: 7d
|
||||
ccache_size: 700M # Release + package: ~0.4 GB at level 0
|
||||
cmake_generator: Ninja
|
||||
make_package: 1
|
||||
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
|
||||
|
||||
ccache_eviction_age: 7d
|
||||
ccache_size: 700M # Debug + suite: ~0.5 GB at level 0
|
||||
cmake_generator: Ninja
|
||||
qt_version: 6.11.*
|
||||
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
||||
|
|
@ -589,6 +714,9 @@ jobs:
|
|||
qt_version: 6.11.*
|
||||
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
||||
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
|
||||
# ABI hash covers cl.exe, which is identical, so this leg keeps
|
||||
# restoring the warm archives. Renaming would orphan them.
|
||||
|
|
@ -602,40 +730,60 @@ jobs:
|
|||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 100
|
||||
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/
|
||||
# macOS configurations compile about twice the TUs of a Linux one, so each of these
|
||||
# three legs gets a bound above what it has been measured to write. The four macOS
|
||||
# legs used to be bounded at 600M from the same quota as the Linux ones; the three
|
||||
# pull-request legs now get 700M each while the nightly Intel leg keeps none, and
|
||||
# that is what fits (see CCACHE_SIZE in build-linux for the full arithmetic).
|
||||
CCACHE_SIZE: 700M
|
||||
PERSIST_CCACHE: 'true'
|
||||
CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }}
|
||||
PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }}
|
||||
# MSVC has no -g level, so there is nothing to ask for on Windows: level 0 is the one
|
||||
# value with a meaning there, and it drops /Z7 rather than lowering a level. Pull-request
|
||||
# builds carry no debug information, which is most of what a compiler cache stores; a
|
||||
# tag is the build whose binary is shipped and symbolicated, so it keeps the full set.
|
||||
COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '0' || '' }}
|
||||
|
||||
# Shared with build-vcpkg-nightly so the two leg sets run the same steps.
|
||||
steps: &vcpkg-steps
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: "[Windows] Add msbuild to PATH"
|
||||
if: matrix.os == 'Windows'
|
||||
uses: microsoft/setup-msbuild@v3
|
||||
uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0
|
||||
with:
|
||||
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"
|
||||
if: matrix.os == 'macOS' && matrix.use_ccache == 1
|
||||
run: brew install ccache
|
||||
|
||||
- name: "[macOS] Restore compiler cache (ccache)"
|
||||
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true'
|
||||
- name: "Restore compiler cache (ccache)"
|
||||
if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true'
|
||||
id: restore_ccache
|
||||
uses: actions/cache/restore@v6
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: ccache-${{ matrix.os }}${{ matrix.override_target }}-${{ matrix.runner }}
|
||||
key: ccache-${{ matrix.os }}${{ matrix.target }}
|
||||
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"
|
||||
if: matrix.os == 'macOS'
|
||||
run: pipx install aqtinstall
|
||||
|
|
@ -652,17 +800,18 @@ jobs:
|
|||
- name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }}"
|
||||
if: matrix.os == 'macOS'
|
||||
id: restore_qt
|
||||
uses: actions/cache/restore@v6
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
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
|
||||
|
||||
# Using jurplel/install-qt-action to install Qt without using brew
|
||||
# Qt build using vcpkg either just fails or takes too long to build
|
||||
- name: "[macOS] Install fat Qt ${{ matrix.qt_version }}"
|
||||
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
|
||||
uses: jurplel/install-qt-action@v4
|
||||
uses: jurplel/install-qt-action@b344308e4483fc892e0d27b3cd1b173ebdd95f06 # v4.5.0
|
||||
with:
|
||||
archives: ${{ env.QT_ARCHIVES }}
|
||||
cache: false
|
||||
# cache-key-prefix: Qt
|
||||
dir: ${{ github.workspace }} # thinning script depends on this location
|
||||
|
|
@ -675,17 +824,18 @@ jobs:
|
|||
|
||||
- name: "[macOS] Cache thin Qt libraries"
|
||||
if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' && github.ref == 'refs/heads/master'
|
||||
uses: actions/cache/save@v6
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: ${{ steps.restore_qt.outputs.cache-primary-key }}
|
||||
path: ${{ github.workspace }}/Qt
|
||||
|
||||
- name: "[Windows] Install Qt ${{ matrix.qt_version }}"
|
||||
if: matrix.os == 'Windows'
|
||||
uses: jurplel/install-qt-action@v4
|
||||
uses: jurplel/install-qt-action@b344308e4483fc892e0d27b3cd1b173ebdd95f06 # v4.5.0
|
||||
with:
|
||||
# Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released
|
||||
aqtsource: git+https://github.com/miurahr/aqtinstall.git
|
||||
archives: ${{ env.QT_ARCHIVES }}
|
||||
cache: ${{ github.ref == 'refs/heads/master' }}
|
||||
cache-key-prefix: Qt
|
||||
modules: ${{ matrix.qt_modules }}
|
||||
|
|
@ -694,7 +844,7 @@ jobs:
|
|||
- name: "[Windows] Install NSIS"
|
||||
if: matrix.os == 'Windows'
|
||||
shell: bash
|
||||
run: choco install nsis
|
||||
run: choco install nsis --no-progress
|
||||
|
||||
# Ninja invokes cl.exe directly and needs the MSVC environment on PATH.
|
||||
- name: "[Windows] Set up MSVC environment"
|
||||
|
|
@ -705,7 +855,7 @@ jobs:
|
|||
|
||||
- name: "Setup vcpkg cache"
|
||||
id: vcpkg-cache
|
||||
uses: TAServers/vcpkg-cache@v3
|
||||
uses: TAServers/vcpkg-cache@4f07be616a4604dd29f3c6885975cf2c04abee71 # v3.4.2
|
||||
with:
|
||||
# Every entry is keyed by vcpkg ABI hash, and one hash set exists per platform,
|
||||
# target and toolchain. The action restores every entry it finds under the
|
||||
|
|
@ -774,6 +924,21 @@ jobs:
|
|||
WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }}
|
||||
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"
|
||||
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
|
||||
|
|
@ -950,7 +1115,7 @@ jobs:
|
|||
- name: "Upload new vcpkg entries"
|
||||
# An empty count means the step above did not run at all, on master and on forks
|
||||
if: steps.vcpkg_new.outputs.count != '' && steps.vcpkg_new.outputs.count != '0'
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
if-no-files-found: error # a count above zero with nothing staged is a bug
|
||||
name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }}
|
||||
|
|
@ -958,9 +1123,11 @@ jobs:
|
|||
retention-days: 1
|
||||
|
||||
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
|
||||
- name: "[macOS] Delete remote compiler cache (ccache)"
|
||||
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
|
||||
- name: "Delete remote compiler cache (ccache)"
|
||||
if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
|
||||
continue-on-error: true
|
||||
# Bash, not the Windows runner's default pwsh, which cannot parse this `if`.
|
||||
shell: bash
|
||||
env:
|
||||
CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
@ -969,9 +1136,9 @@ jobs:
|
|||
echo "Cache deleted successfully"
|
||||
fi
|
||||
|
||||
- name: "[macOS] Cache updated compiler cache (ccache)"
|
||||
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master'
|
||||
uses: actions/cache/save@v6
|
||||
- name: "Cache updated compiler cache (ccache)"
|
||||
if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
|
||||
path: ${{ env.CCACHE_DIR }}
|
||||
|
|
@ -1026,7 +1193,7 @@ jobs:
|
|||
- name: "Upload artifact"
|
||||
if: matrix.make_package
|
||||
id: upload_artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
archive: false
|
||||
if-no-files-found: error
|
||||
|
|
@ -1058,7 +1225,7 @@ jobs:
|
|||
# debug identifier, and the PE is what carries the unwind information.
|
||||
- name: "[Windows] Upload debug symbols (exe + pdb)"
|
||||
if: matrix.os == 'Windows' && needs.configure.outputs.tag != null
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
if-no-files-found: error
|
||||
name: ${{ steps.build.outputs.name }}-Symbols
|
||||
|
|
@ -1073,7 +1240,7 @@ jobs:
|
|||
- name: "Attest binary provenance"
|
||||
if: steps.upload_release.outcome == 'success'
|
||||
id: attestation
|
||||
uses: actions/attest@v4
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
show-summary: false
|
||||
subject-path: ${{ steps.build.outputs.path }}
|
||||
|
|
@ -1096,7 +1263,6 @@ jobs:
|
|||
runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md
|
||||
# macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout
|
||||
|
||||
ccache_eviction_age: 7d
|
||||
cmake_generator: Ninja
|
||||
make_package: 1
|
||||
override_target: 13
|
||||
|
|
@ -1105,7 +1271,6 @@ jobs:
|
|||
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
||||
soc: Intel
|
||||
type: Release
|
||||
use_ccache: 1
|
||||
vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/'
|
||||
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'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
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
|
||||
|
||||
prune-caches:
|
||||
|
|
@ -1149,6 +1308,16 @@ jobs:
|
|||
# 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
|
||||
# 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"
|
||||
shell: bash
|
||||
env:
|
||||
|
|
@ -1159,9 +1328,10 @@ jobs:
|
|||
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
|
||||
# (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
|
||||
# instead of waiting out the age rule.
|
||||
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 nightly legs and the pull-request legs that finish under the Windows leg
|
||||
# keep none, so their leftover entries are reclaimed here instead of waiting out
|
||||
# the age rule.
|
||||
LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Windows10 ccache-macOS14 ccache-macOS15 ccache-macOS27'
|
||||
run: |
|
||||
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"
|
||||
|
|
@ -1169,15 +1339,19 @@ jobs:
|
|||
cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))"
|
||||
live=" ${LIVE_VCPKG_SCOPES} "
|
||||
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
|
||||
while IFS=$'\t' read -r id ref key accessed; do
|
||||
[[ -n $id ]] || continue
|
||||
# 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
|
||||
# never be deleted by accident
|
||||
# come from docker-release.yml, codeql-trap-* from codeql-action in codeql.yml),
|
||||
# so that an entry of some other workflow can never be deleted by accident
|
||||
case "$key" in
|
||||
vcpkg/* | ccache-* | Qt* | buildkit-* | index-*) ;;
|
||||
vcpkg/* | ccache-* | Qt* | buildkit-* | index-* | codeql-trap-*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
|
||||
|
|
@ -1185,8 +1359,9 @@ jobs:
|
|||
if [[ $ref != refs/heads/master ]]; then
|
||||
reason="scoped to $ref"
|
||||
elif [[ $key == ccache-* ]]; then
|
||||
# An exact token compare: ccache-Ubuntu26.04 is a prefix of
|
||||
# ccache-Ubuntu26.04sanitize, and both keys are live
|
||||
# An exact token compare, because one key can be a prefix of another:
|
||||
# ccache-Fedora45 is a prefix of ccache-Fedora45debug and
|
||||
# ccache-Fedora45release, and only the latter two are live.
|
||||
if [[ $live_ccache != *" $key "* ]]; then
|
||||
reason="no pull-request leg keeps it"
|
||||
fi
|
||||
|
|
@ -1197,6 +1372,10 @@ jobs:
|
|||
if [[ $live != *" $scope "* ]]; then
|
||||
reason="no matrix leg uses $scope"
|
||||
fi
|
||||
elif [[ $key == codeql-trap-* ]]; then
|
||||
if [[ $key != "$newest_trap" ]]; then
|
||||
reason="superseded by the newest trap cache"
|
||||
fi
|
||||
fi
|
||||
if [[ -z $reason ]]; then
|
||||
stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)"
|
||||
|
|
|
|||
9
.github/workflows/desktop-lint.yml
vendored
9
.github/workflows/desktop-lint.yml
vendored
|
|
@ -1,7 +1,7 @@
|
|||
name: Code Style (C++)
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: read # needed to checkout repo
|
||||
|
||||
on:
|
||||
# Push trigger not needed for linting, we do not allow direct pushes to master
|
||||
|
|
@ -12,7 +12,7 @@ on:
|
|||
- '!.ci/**'
|
||||
- '!.github/**'
|
||||
- '!.tx/**'
|
||||
- '!doc/**'
|
||||
- '!docs/**'
|
||||
- '.ci/lint_cpp.sh'
|
||||
- '.github/workflows/desktop-lint.yml'
|
||||
- '.clang-format'
|
||||
|
|
@ -21,11 +21,12 @@ on:
|
|||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-slim
|
||||
name: "Check formatting"
|
||||
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
|
||||
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 20 # should be enough to find merge base
|
||||
|
||||
|
|
|
|||
22
.github/workflows/docker-release.yml
vendored
22
.github/workflows/docker-release.yml
vendored
|
|
@ -56,7 +56,7 @@ jobs:
|
|||
include:
|
||||
- label: x86
|
||||
platform: linux/amd64
|
||||
runner: ubuntu-latest # https://github.com/actions/runner-images
|
||||
runner: ubuntu-latest # https://github.com/actions/runner-images#available-images
|
||||
|
||||
- label: arm
|
||||
platform: linux/arm64
|
||||
|
|
@ -67,17 +67,17 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ case(github.event_name == 'workflow_dispatch', format('refs/tags/{0}', inputs.tag), '') }}
|
||||
|
||||
- name: "Set up Docker buildx"
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: "Login to GitHub Container Registry (GHCR)"
|
||||
if: (github.event_name == 'release' && github.event.release.prerelease == false) || github.event_name == 'workflow_dispatch'
|
||||
id: login
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
|
@ -92,7 +92,7 @@ jobs:
|
|||
# cache held. The final runtime stage is the part that still pays off.
|
||||
- name: "Build image"
|
||||
if: steps.login.outcome != 'success'
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
|
||||
cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=min,scope={0}', env.CACHE_SCOPE), '') }}
|
||||
|
|
@ -104,7 +104,7 @@ jobs:
|
|||
- name: "Build image and push by digest"
|
||||
if: steps.login.outcome == 'success'
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||
with:
|
||||
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
|
||||
cache-to: type=gha,mode=min,scope=${{ env.CACHE_SCOPE }}
|
||||
|
|
@ -115,7 +115,7 @@ jobs:
|
|||
org.opencontainers.image.url=${{ env.OCI_URL }}
|
||||
outputs: type=image,name=${{ env.GHCR_IMAGE }},name-canonical=true,push=true,push-by-digest=true
|
||||
platforms: ${{ matrix.platform }}
|
||||
provenance: mode=max # Do not pass secrets as build arguments with this option
|
||||
provenance: mode=max # do not pass secrets as build arguments with this option
|
||||
sbom: true
|
||||
|
||||
- name: "Export digest"
|
||||
|
|
@ -128,7 +128,7 @@ jobs:
|
|||
|
||||
- name: "Upload digest"
|
||||
if: steps.login.outcome == 'success'
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
if-no-files-found: error
|
||||
name: digest-${{ matrix.label }}
|
||||
|
|
@ -145,14 +145,14 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Download digests"
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: "Login to GitHub Container Registry (GHCR)"
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
|
@ -160,7 +160,7 @@ jobs:
|
|||
|
||||
- name: "Docker metadata"
|
||||
id: metadata
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||
with:
|
||||
images: ${{ env.GHCR_IMAGE }}
|
||||
flavor: |
|
||||
|
|
|
|||
18
.github/workflows/documentation-build.yml
vendored
18
.github/workflows/documentation-build.yml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
name: Generate Docs
|
||||
name: Build Documentation
|
||||
|
||||
permissions:
|
||||
contents: read # write permission to the destination repo come from 'deploy_key'
|
||||
|
|
@ -6,7 +6,7 @@ permissions:
|
|||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'doc/doxygen/**'
|
||||
- 'docs/**'
|
||||
- '.github/workflows/documentation-build.yml'
|
||||
- 'Doxyfile'
|
||||
release:
|
||||
|
|
@ -24,12 +24,12 @@ env:
|
|||
|
||||
jobs:
|
||||
docs:
|
||||
name: Doxygen
|
||||
runs-on: ubuntu-latest
|
||||
name: "Doxygen"
|
||||
runs-on: ubuntu-latest # https://github.com/actions/runner-images#available-images
|
||||
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
|
|
@ -40,9 +40,9 @@ jobs:
|
|||
dot -V
|
||||
|
||||
- name: "Install Doxygen"
|
||||
uses: ssciwr/doxygen-install@v2
|
||||
uses: ssciwr/doxygen-install@fa3e94d34131d3990b6b8ba939ab982964aa3758 # v2.0.3
|
||||
with:
|
||||
version: "1.18.0"
|
||||
version: "1.18.0" # https://www.doxygen.nl/manual/changelog.html
|
||||
|
||||
- name: "Update Doxygen Configuration"
|
||||
shell: bash
|
||||
|
|
@ -65,13 +65,13 @@ jobs:
|
|||
|
||||
- name: "Deploy to cockatrice.github.io/docs"
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0
|
||||
with:
|
||||
deploy_key: ${{ secrets.DOCS_DEPLOY_KEY }}
|
||||
destination_dir: docs # docs will be available at https://cockatrice.github.io/docs/
|
||||
external_repository: Cockatrice/cockatrice.github.io
|
||||
publish_branch: master
|
||||
publish_dir: ./docs/html
|
||||
publish_dir: ./docs-output/html
|
||||
|
||||
- name: Link to Documentation Page
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
|
|
|
|||
11
.github/workflows/translations-pull.yml
vendored
11
.github/workflows/translations-pull.yml
vendored
|
|
@ -16,20 +16,19 @@ on:
|
|||
|
||||
jobs:
|
||||
translations:
|
||||
name: "Pull languages"
|
||||
# Do not run the scheduled workflow on forks
|
||||
if: github.event_name != 'schedule' || github.repository_owner == 'Cockatrice'
|
||||
|
||||
name: Pull languages
|
||||
runs-on: ubuntu-slim
|
||||
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
|
||||
|
||||
steps:
|
||||
- name: "Checkout repo"
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: "Pull translated strings from Transifex"
|
||||
# Do not run this step for PR's from forks, they don't have access to the secret
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
|
||||
uses: transifex/cli-action@v2
|
||||
uses: transifex/cli-action@584fd205cbe598773b5a81ce711fa44842678189 # v2
|
||||
with:
|
||||
# Used config file: https://github.com/Cockatrice/Cockatrice/blob/master/.tx/config
|
||||
# Docs: https://github.com/transifex/cli#pulling-files-from-transifex
|
||||
|
|
@ -39,7 +38,7 @@ jobs:
|
|||
- name: "Create pull request"
|
||||
if: github.event_name != 'pull_request'
|
||||
id: create_pr
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
add-paths: |
|
||||
cockatrice/translations/*.ts
|
||||
|
|
|
|||
19
.github/workflows/translations-push.yml
vendored
19
.github/workflows/translations-push.yml
vendored
|
|
@ -16,15 +16,14 @@ on:
|
|||
|
||||
jobs:
|
||||
translations:
|
||||
name: "Push strings"
|
||||
# Do not run the scheduled workflow on forks
|
||||
if: github.event_name != 'schedule' || github.repository_owner == 'Cockatrice'
|
||||
|
||||
name: Push strings
|
||||
runs-on: ubuntu-slim
|
||||
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
|
||||
|
||||
steps:
|
||||
- name: "Checkout repo"
|
||||
uses: actions/checkout@v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: "Install lupdate"
|
||||
shell: bash
|
||||
|
|
@ -37,20 +36,22 @@ jobs:
|
|||
FILE: cockatrice/cockatrice_en@source.ts
|
||||
id: cockatrice
|
||||
shell: bash
|
||||
run: >
|
||||
DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')"
|
||||
run: |
|
||||
export DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')"
|
||||
.ci/update_translation_source_strings.sh
|
||||
|
||||
- name: "Update Oracle translation source"
|
||||
id: oracle
|
||||
shell: bash
|
||||
env:
|
||||
DIRS: 'oracle/src'
|
||||
FILE: 'oracle/oracle_en@source.ts'
|
||||
DIRS: oracle/src
|
||||
FILE: oracle/oracle_en@source.ts
|
||||
run: .ci/update_translation_source_strings.sh
|
||||
|
||||
- name: "Render template"
|
||||
id: template
|
||||
# Not pinnable to a SHA: the action downloads its release binary by ref name,
|
||||
# so a commit ref matches no release tag.
|
||||
uses: chuhlomin/render-template/binary@v1
|
||||
with:
|
||||
template: .ci/update_translation_source_strings_template.md
|
||||
|
|
@ -62,7 +63,7 @@ jobs:
|
|||
- name: "Create pull request"
|
||||
if: github.event_name != 'pull_request'
|
||||
id: create_pr
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
add-paths: |
|
||||
cockatrice/cockatrice_en@source.ts
|
||||
|
|
|
|||
27
.github/workflows/vcpkg-cache-import.yml
vendored
27
.github/workflows/vcpkg-cache-import.yml
vendored
|
|
@ -23,8 +23,10 @@ permissions:
|
|||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build Desktop"]
|
||||
types: [completed]
|
||||
workflows:
|
||||
- 'Build Desktop'
|
||||
types:
|
||||
- completed
|
||||
|
||||
# Every import writes the same scopes, so let them queue instead of racing each other
|
||||
concurrency:
|
||||
|
|
@ -33,12 +35,12 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
list:
|
||||
name: List new vcpkg entries
|
||||
name: "List new vcpkg entries"
|
||||
# Only a successful push to the default branch has archives worth importing.
|
||||
# Only code that landed on master may populate the scope master runs restore.
|
||||
# Bot actors are excluded on top: their runs must never promote binaries into
|
||||
# the shared scope even if they somehow target the default branch.
|
||||
if: >-
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
(github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') &&
|
||||
github.event.workflow_run.head_branch == 'master' &&
|
||||
|
|
@ -46,6 +48,7 @@ jobs:
|
|||
!endsWith(github.event.workflow_run.actor.login, '[bot]') &&
|
||||
!endsWith(github.event.workflow_run.triggering_actor.login, '[bot]')
|
||||
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
|
||||
|
||||
# workflow_run is a low-trust trigger, so cache access is read-only by default even
|
||||
# though this run resolves to the default branch. Assert below that this took effect,
|
||||
# because a save under read-only access only warns and would import nothing silently.
|
||||
|
|
@ -55,7 +58,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Download new vcpkg entries"
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: vcpkg-new-*
|
||||
path: incoming
|
||||
|
|
@ -66,7 +69,7 @@ jobs:
|
|||
shell: bash
|
||||
run: |
|
||||
if [[ $ACTIONS_CACHE_MODE != *write* ]]; then
|
||||
echo "::error::expected a write-capable cache, got '$ACTIONS_CACHE_MODE'"
|
||||
echo "::error::Expected a write-capable cache, got '$ACTIONS_CACHE_MODE'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -88,14 +91,16 @@ jobs:
|
|||
# Compact, because a multi line value cannot be written to GITHUB_OUTPUT
|
||||
entries="$(jq -cs '.' <<<"$entries")"
|
||||
echo "entries=$entries" >>"$GITHUB_OUTPUT"
|
||||
echo "::notice::importing $(jq length <<<"$entries") vcpkg entries"
|
||||
echo "::notice::Importing $(jq length <<<"$entries") vcpkg entries"
|
||||
|
||||
save:
|
||||
name: "Save ${{ matrix.entry.key }}"
|
||||
needs: list
|
||||
if: needs.list.outputs.entries
|
||||
runs-on: ubuntu-slim
|
||||
needs: list
|
||||
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
|
||||
|
||||
cache-mode: write
|
||||
|
||||
strategy:
|
||||
# One job per archive, they run in parallel and each only uploads a single file
|
||||
fail-fast: false
|
||||
|
|
@ -105,7 +110,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: "Download new vcpkg entries"
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: ${{ matrix.entry.artifact }}
|
||||
path: incoming
|
||||
|
|
@ -113,7 +118,7 @@ jobs:
|
|||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: "Save vcpkg entry"
|
||||
uses: actions/cache/save@v6
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
# The payload is a Windows or macOS archive while the upload runs on Linux, and
|
||||
# without this the entry would be stamped as a Linux cache that neither of them
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -15,4 +15,4 @@ compile_commands.json
|
|||
.cache
|
||||
.gdb_history
|
||||
cockatrice/resources/config/qtlogging.ini
|
||||
docs/
|
||||
docs-output/
|
||||
|
|
|
|||
2
.gitmodules
vendored
2
.gitmodules
vendored
|
|
@ -2,7 +2,7 @@
|
|||
path = vcpkg
|
||||
url = https://github.com/microsoft/vcpkg.git
|
||||
[submodule "doxygen-awesome-css"]
|
||||
path = doc/doxygen/theme
|
||||
path = docs/doxygen/theme
|
||||
url = https://github.com/jothepro/doxygen-awesome-css.git
|
||||
[submodule "external/qttesting"]
|
||||
path = external/qttesting
|
||||
|
|
|
|||
198
CMakeLists.txt
198
CMakeLists.txt
|
|
@ -1,11 +1,11 @@
|
|||
# Cockatrice's main CMakeLists.txt
|
||||
#
|
||||
# This is basically a wrapper to enable/disable the compilation
|
||||
# of the different projects: servatrice, cockatrice, test
|
||||
# This is basically a wrapper to enable/disable the compilation of the different projects:
|
||||
# Cockatrice, Oracle, Servatrice, Test
|
||||
# This file sets all the variables shared between the projects
|
||||
# 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)
|
||||
|
||||
# Use compiler cache (ccache)
|
||||
|
|
@ -40,24 +40,20 @@ if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/
|
|||
endif()
|
||||
|
||||
# Default to "Release" build type
|
||||
# User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call
|
||||
if(DEFINED CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE
|
||||
${CMAKE_BUILD_TYPE}
|
||||
CACHE STRING "Type of build"
|
||||
)
|
||||
else()
|
||||
# A user-provided CMAKE_BUILD_TYPE must be checked before the PROJECT() call
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE
|
||||
Release
|
||||
CACHE STRING "Type of build"
|
||||
CACHE STRING "Build type"
|
||||
)
|
||||
endif()
|
||||
|
||||
# ccache does not support MSVC and must not auto-engage on Windows
|
||||
# (it is installed unintentionally on the Windows CI runner).
|
||||
# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows
|
||||
# also opts out of ccache even though the GNUCXX branch below supports it.
|
||||
if(USE_CCACHE AND NOT WIN32)
|
||||
# ccache drives MSVC too (since 4.7), but only when the compiler writes debug
|
||||
# information into the object file: /Zi and /ZI keep it in a shared PDB, and ccache
|
||||
# reports them as unsupported rather than risk a stale one. The MSVC flags below
|
||||
# therefore use /Z7. MSVC precompiled headers are cached as well, with the same
|
||||
# sloppiness the other toolchains need.
|
||||
if(USE_CCACHE)
|
||||
find_program(CCACHE_PROGRAM ccache)
|
||||
if(CCACHE_PROGRAM)
|
||||
# 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.
|
||||
execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
|
||||
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()
|
||||
elseif(USE_CCACHE AND WIN32)
|
||||
# An explicit opt-in must not disappear silently on Windows.
|
||||
message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows")
|
||||
endif()
|
||||
|
||||
if(WIN32 OR USE_VCPKG)
|
||||
|
|
@ -108,20 +104,21 @@ endif()
|
|||
# Version can be overriden by git tags, see cmake/getversion.cmake
|
||||
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)
|
||||
set(GIT_TAG_RELEASENAME "Reality Check")
|
||||
endif()
|
||||
|
||||
# Use c++20 for all targets
|
||||
# Use C++20 for all targets
|
||||
set(CMAKE_CXX_STANDARD
|
||||
20
|
||||
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(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS true)
|
||||
set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS ON)
|
||||
|
||||
# Search path for cmake modules
|
||||
set(COCKATRICE_CMAKE_PATH "${PROJECT_SOURCE_DIR}/cmake")
|
||||
|
|
@ -134,31 +131,31 @@ include(createversionfile)
|
|||
|
||||
# Define a proper install path
|
||||
if(UNIX)
|
||||
if(APPLE)
|
||||
# macOS
|
||||
# Due to the special bundle structure ignore
|
||||
# the prefix eventually set by the user.
|
||||
if(APPLE) # macOS
|
||||
# Due to the special bundle structure ignore the prefix eventually set by the user
|
||||
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/release)
|
||||
|
||||
# Force ccache usage if available
|
||||
get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE)
|
||||
if(RULE_LAUNCH_COMPILE)
|
||||
message(STATUS "Force enabling CCache usage under macOS")
|
||||
# Set up wrapper scripts
|
||||
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")
|
||||
# Force ccache usage if requested and available
|
||||
if(USE_CCACHE)
|
||||
get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE)
|
||||
if(RULE_LAUNCH_COMPILE)
|
||||
message(STATUS "Force enabling ccache usage under macOS")
|
||||
|
||||
# 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")
|
||||
# Set up wrapper scripts
|
||||
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(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()
|
||||
else()
|
||||
# Linux / BSD
|
||||
else() # Linux / BSD
|
||||
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
|
||||
#fix package build
|
||||
# Fix package build
|
||||
if(PREFIX)
|
||||
set(CMAKE_INSTALL_PREFIX ${PREFIX})
|
||||
else()
|
||||
|
|
@ -166,47 +163,68 @@ if(UNIX)
|
|||
endif()
|
||||
endif()
|
||||
endif()
|
||||
elseif(WIN32)
|
||||
elseif(WIN32) # Windows (including 64-bit)
|
||||
set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/rundir/${CMAKE_BUILD_TYPE})
|
||||
endif()
|
||||
|
||||
# Define proper compilation flags
|
||||
if(MSVC)
|
||||
# Disable Warning C4251, C++20 compatibility, Multi-threaded Builds, Warn Detection, Unwind Semantics, Debug Symbols
|
||||
set(CMAKE_CXX_FLAGS "/wd4251 /Zc:__cplusplus /std:c++20 /permissive- /W4 /MP /EHsc /Zi")
|
||||
# Visual Studio: Maximum Optimization, Multi-threaded DLL
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "/Ox /MD")
|
||||
# Visual Studio: No Optimization, Multi-threaded Debug DLL
|
||||
# Define compiler flags
|
||||
if(MSVC) # Microsoft Visual C++ compiler
|
||||
# /EHsc Enable standard C++ exception handling
|
||||
# /MP Enable parallel compilation
|
||||
# /permissive- Enable more standards-conforming behavior
|
||||
# /utf-8 Set source file encoding and execution character set to UTF-8
|
||||
# /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")
|
||||
|
||||
# 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")
|
||||
|
||||
add_compile_definitions(_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING)
|
||||
elseif(CMAKE_COMPILER_IS_GNUCXX)
|
||||
# linux/gcc, bsd/gcc, windows/mingw
|
||||
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") # GCC compiler
|
||||
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)
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra -Werror")
|
||||
else()
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra")
|
||||
endif()
|
||||
|
||||
if(APPLE)
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++20")
|
||||
endif()
|
||||
|
||||
# -Wcast-align Catch unsafe pointer casts
|
||||
# -Wmissing-declarations Catch defined functions without a prior declaration
|
||||
# -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
|
||||
-Wcast-align
|
||||
-Wmissing-declarations
|
||||
-Wno-long-long
|
||||
-Wno-error=extra
|
||||
-Wno-error=delete-non-virtual-dtor
|
||||
-Wno-error=sign-compare
|
||||
-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})
|
||||
|
|
@ -218,9 +236,16 @@ elseif(CMAKE_COMPILER_IS_GNUCXX)
|
|||
|
||||
# Reduce compiler I/O by using pipes between stages instead of temp files
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
|
||||
else()
|
||||
# other: osx/llvm, bsd/llvm
|
||||
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang and AppleClang compilers
|
||||
# -O2 Balanced optimization
|
||||
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)
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra -Werror -Wno-unused-parameter")
|
||||
else()
|
||||
|
|
@ -229,6 +254,8 @@ else()
|
|||
|
||||
# Reduce compiler I/O by using pipes between stages instead of temp files
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
|
||||
else() # Unknown compiler
|
||||
message(WARNING "Unknown C++ compiler: ${CMAKE_CXX_COMPILER_ID}")
|
||||
endif()
|
||||
|
||||
# Always included, because they define cockatrice_disable_sanitizers_for() and
|
||||
|
|
@ -238,9 +265,37 @@ include(Sanitizers)
|
|||
include(Coverage)
|
||||
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")
|
||||
add_definitions("-DSFMT_MEXP=19937")
|
||||
add_compile_definitions(SFMT_MEXP=19937)
|
||||
endif()
|
||||
|
||||
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!")
|
||||
endif()
|
||||
|
||||
#Find OpenSSL
|
||||
if(WIN32)
|
||||
# Find OpenSSL
|
||||
if(WIN32) # Windows (including 64-bit)
|
||||
find_package(OpenSSL REQUIRED)
|
||||
if(OPENSSL_FOUND)
|
||||
include_directories(${OPENSSL_INCLUDE_DIRS})
|
||||
endif()
|
||||
endif()
|
||||
|
||||
#Find VCredist
|
||||
# Find Visual C++ Redistributable
|
||||
if(MSVC)
|
||||
find_package(VCredistRuntime)
|
||||
endif()
|
||||
|
|
@ -514,3 +569,10 @@ if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3)
|
|||
# Qt6.3+ requires project finalization to support translations
|
||||
qt_finalize_project()
|
||||
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
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
|
||||
# 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
|
||||
# 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
|
||||
# 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 reference definitions. This must be a list of .bib files. The .bib
|
||||
|
|
@ -1091,8 +1091,8 @@ RECURSIVE = YES
|
|||
EXCLUDE = build/ \
|
||||
cmake/ \
|
||||
cmake-build-debug/ \
|
||||
doc/doxygen/theme/docs/ \
|
||||
doc/doxygen/theme/include/ \
|
||||
docs/doxygen/theme/docs/ \
|
||||
docs/doxygen/theme/include/ \
|
||||
vcpkg/
|
||||
|
||||
# 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
|
||||
# \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
|
||||
# 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
|
||||
# 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
|
||||
# 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.
|
||||
# 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
|
||||
# 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.
|
||||
# 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
|
||||
# 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.
|
||||
# This tag requires that the tag GENERATE_HTML is set to YES.
|
||||
|
||||
HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \
|
||||
doc/doxygen/css/hide_nav_sync.css \
|
||||
doc/doxygen/css/cockatrice_docs_style.css
|
||||
HTML_EXTRA_STYLESHEET = docs/doxygen/theme/doxygen-awesome.css \
|
||||
docs/doxygen/css/hide_nav_sync.css \
|
||||
docs/doxygen/css/cockatrice_docs_style.css
|
||||
|
||||
# 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
|
||||
|
|
@ -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.
|
||||
# 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
|
||||
# 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
|
||||
|
||||
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:
|
||||
- [](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.
|
||||
|
|
@ -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 Webpage](https://cockatrice.github.io/)
|
||||
- [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file`
|
||||
- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md)
|
||||
- [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.
|
||||
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 |
|
||||
|
||||
> [!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
|
||||
> doxygen Doxyfile
|
||||
> ```
|
||||
|
|
|
|||
|
|
@ -216,6 +216,8 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/deck_editor/deck_state_manager.cpp
|
||||
src/interface/widgets/deck_editor/deck_zone_dialog.cpp
|
||||
src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp
|
||||
src/interface/widgets/game_hosting/create_game_form_widget.cpp
|
||||
src/interface/widgets/game_hosting/game_creation_options.cpp
|
||||
src/interface/widgets/general/background_sources.cpp
|
||||
src/interface/widgets/general/display/background_plate_widget.cpp
|
||||
src/interface/widgets/general/display/banner_widget.cpp
|
||||
|
|
@ -291,9 +293,14 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/settings_page/storage_settings_page.cpp
|
||||
src/interface/widgets/settings_page/user_interface_settings_page.cpp
|
||||
src/interface/widgets/utility/custom_line_edit.cpp
|
||||
src/interface/widgets/utility/epoch_format.h
|
||||
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.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/visibility_change_listener.cpp
|
||||
src/interface/widgets/utility/visibility_change_listener.h
|
||||
|
|
@ -381,9 +388,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/tab_edhrec.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_account.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_deck_editor.cpp
|
||||
src/interface/widgets/tabs/tab_deck_storage.cpp
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
#include "update_downloader.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QChar>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QIODevice>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSaveFile>
|
||||
#include <QTemporaryDir>
|
||||
#include <QUrl>
|
||||
#include <QVariant>
|
||||
#include <libcockatrice/network/network_manager.h>
|
||||
#include <utility>
|
||||
|
||||
UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr)
|
||||
{
|
||||
|
|
@ -23,48 +24,116 @@ void UpdateDownloader::beginDownload(QUrl 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));
|
||||
connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished);
|
||||
connect(response, &QNetworkReply::errorOccurred, this, &UpdateDownloader::downloadError);
|
||||
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;
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
// If we finished but there's a redirect, follow it
|
||||
QVariant redirect = response->attribute(QNetworkRequest::RedirectionTargetAttribute);
|
||||
if (!redirect.isNull()) {
|
||||
beginDownload(redirect.toUrl());
|
||||
// An aborted, superseded reply also emits finished(). Only the reply
|
||||
// currently stored in response may be consumed.
|
||||
if (response == nullptr || sender() != response) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle any errors we had
|
||||
if (response->error()) {
|
||||
emit error(response->errorString());
|
||||
// errorOccurred normally reports this first; keep the check for errors that
|
||||
// only surface when the reply finishes.
|
||||
if (response->error() != QNetworkReply::NoError) {
|
||||
downloadError(response->error());
|
||||
response->deleteLater();
|
||||
response = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
// Work out the file name of the download
|
||||
QString fileName = QDir::temp().path() + QDir::separator() + originalUrl.toString().section('/', -1);
|
||||
// Give the update its own private directory. The name is unpredictable, so a
|
||||
// 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
|
||||
QFile file(fileName);
|
||||
const QString remoteName = QFileInfo(originalUrl.path()).fileName();
|
||||
const QString fileName = downloadDir.filePath(remoteName.isEmpty() ? QStringLiteral("update") : remoteName);
|
||||
|
||||
QSaveFile file(fileName);
|
||||
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;
|
||||
}
|
||||
|
||||
file.write(response->readAll());
|
||||
file.close();
|
||||
const QByteArray data = response->readAll();
|
||||
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 downloadSuccessful(QUrl::fromLocalFile(fileName));
|
||||
|
|
|
|||
|
|
@ -32,10 +32,13 @@ private:
|
|||
QUrl originalUrl;
|
||||
QNetworkAccessManager *netMan;
|
||||
QNetworkReply *response;
|
||||
bool errorReported = false;
|
||||
bool cancelRequested = false;
|
||||
private slots:
|
||||
void fileFinished();
|
||||
void downloadProgress(qint64 bytesRead, qint64 totalBytes);
|
||||
void downloadError(QNetworkReply::NetworkError);
|
||||
void cancelDownload();
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_UPDATEDOWNLOADER_H
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ void ArrowItem::onTargetDestroyed()
|
|||
// Drag and attach arrows are never inserted into the arrow registry and
|
||||
// have no server-side counterpart, so no deletion event can clean them
|
||||
// up. Delete them locally when either endpoint is destroyed.
|
||||
delArrow();
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -103,6 +103,12 @@ void ArrowItem::delArrow()
|
|||
deleteLater();
|
||||
}
|
||||
|
||||
void ArrowItem::cancel()
|
||||
{
|
||||
ungrabMouse();
|
||||
delArrow();
|
||||
}
|
||||
|
||||
void ArrowItem::updatePath()
|
||||
{
|
||||
if (!targetItem) {
|
||||
|
|
@ -350,6 +356,17 @@ void ArrowDragItem::addChildArrow(ArrowDragItem *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)
|
||||
{
|
||||
if (targetLocked || !startItem) {
|
||||
|
|
@ -393,8 +410,10 @@ void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
|||
targetItem ? updatePath() : updatePath(endPos);
|
||||
update();
|
||||
|
||||
for (auto *child : childArrows) {
|
||||
child->mouseMoveEvent(event);
|
||||
for (const auto &child : childArrows) {
|
||||
if (child) {
|
||||
child->mouseMoveEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -403,10 +422,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
|||
if (!startItem) {
|
||||
// The source card was destroyed while the arrow was being drawn.
|
||||
// Clean up the arrow and its children instead of leaking them.
|
||||
delArrow();
|
||||
for (auto *child : childArrows) {
|
||||
child->mouseReleaseEvent(event);
|
||||
}
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -415,7 +431,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
|||
// For now, we can safely assume that the start item is always a card.
|
||||
// The target item can be a player as well.
|
||||
if (!startCard) {
|
||||
delArrow();
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -435,7 +451,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
|||
} else if (auto *targetPlayer = qgraphicsitem_cast<PlayerTarget *>(targetItem)) {
|
||||
arrow.targetPlayerId = targetPlayer->getOwner()->getPlayerInfo()->getId();
|
||||
} else {
|
||||
delArrow();
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -448,9 +464,12 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
|||
}
|
||||
|
||||
delArrow();
|
||||
for (auto *child : childArrows) {
|
||||
child->mouseReleaseEvent(event);
|
||||
for (const auto &child : childArrows) {
|
||||
if (child) {
|
||||
child->mouseReleaseEvent(event);
|
||||
}
|
||||
}
|
||||
childArrows.clear();
|
||||
}
|
||||
|
||||
// ArrowAttachItem
|
||||
|
|
@ -471,6 +490,17 @@ void ArrowAttachItem::addChildArrow(ArrowAttachItem *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)
|
||||
{
|
||||
if (targetLocked || !startItem) {
|
||||
|
|
@ -509,8 +539,10 @@ void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
|
|||
targetItem ? updatePath() : updatePath(endPos);
|
||||
update();
|
||||
|
||||
for (auto *child : childArrows) {
|
||||
child->mouseMoveEvent(event);
|
||||
for (const auto &child : childArrows) {
|
||||
if (child) {
|
||||
child->mouseMoveEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -519,17 +551,16 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
|||
if (!startItem) {
|
||||
// The source card was destroyed while the arrow was being drawn.
|
||||
// Clean up the arrow and its children instead of leaking them.
|
||||
delArrow();
|
||||
for (auto *child : childArrows) {
|
||||
child->mouseReleaseEvent(event);
|
||||
}
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Attaching could move startItem under the current cursor position, causing all children to retarget to it right
|
||||
// before they are processed. Prevent that.
|
||||
for (auto *child : childArrows) {
|
||||
child->setTargetLocked(true);
|
||||
for (const auto &child : childArrows) {
|
||||
if (child) {
|
||||
child->setTargetLocked(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetItem && targetItem != startItem) {
|
||||
|
|
@ -541,7 +572,10 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
|||
}
|
||||
|
||||
delArrow();
|
||||
for (auto *child : childArrows) {
|
||||
child->mouseReleaseEvent(event);
|
||||
for (const auto &child : childArrows) {
|
||||
if (child) {
|
||||
child->mouseReleaseEvent(event);
|
||||
}
|
||||
}
|
||||
childArrows.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ public:
|
|||
|
||||
void onTargetDestroyed();
|
||||
void delArrow();
|
||||
/// Abandons an in-progress drag arrow, discarding any child arrows with it.
|
||||
virtual void cancel();
|
||||
void updatePath();
|
||||
void updatePath(const QPointF &endPoint);
|
||||
void startDrawAnimation();
|
||||
|
|
@ -112,12 +114,13 @@ class ArrowDragItem : public ArrowItem
|
|||
private:
|
||||
PlayerLogic *player;
|
||||
int deleteInPhase;
|
||||
QList<ArrowDragItem *> childArrows;
|
||||
QList<QPointer<ArrowDragItem>> childArrows;
|
||||
QMetaObject::Connection positionConnection;
|
||||
|
||||
public:
|
||||
ArrowDragItem(PlayerLogic *_owner, ArrowTarget *_startItem, const QColor &_color, int _deleteInPhase);
|
||||
void addChildArrow(ArrowDragItem *child);
|
||||
void cancel() override;
|
||||
|
||||
protected:
|
||||
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
|
||||
|
|
@ -129,12 +132,13 @@ class ArrowAttachItem : public ArrowItem
|
|||
Q_OBJECT
|
||||
private:
|
||||
PlayerLogic *player;
|
||||
QList<ArrowAttachItem *> childArrows;
|
||||
QList<QPointer<ArrowAttachItem>> childArrows;
|
||||
QMetaObject::Connection positionConnection;
|
||||
|
||||
public:
|
||||
explicit ArrowAttachItem(ArrowTarget *_startItem);
|
||||
void addChildArrow(ArrowAttachItem *child);
|
||||
void cancel() override;
|
||||
|
||||
protected:
|
||||
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
|
||||
|
|
|
|||
|
|
@ -294,6 +294,13 @@ void CardItem::drawArrow(const QColor &arrowColor)
|
|||
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();
|
||||
PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer());
|
||||
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);
|
||||
scene()->addItem(arrow);
|
||||
pendingArrow = arrow;
|
||||
arrow->grabMouse();
|
||||
|
||||
for (const auto &item : scene()->selectedItems()) {
|
||||
|
|
@ -326,8 +334,14 @@ void CardItem::drawAttachArrow()
|
|||
return;
|
||||
}
|
||||
|
||||
// See drawArrow(): a repeated trigger must not orphan the previous arrow.
|
||||
if (pendingArrow) {
|
||||
pendingArrow->cancel();
|
||||
}
|
||||
|
||||
auto *arrow = new ArrowAttachItem(this);
|
||||
scene()->addItem(arrow);
|
||||
pendingArrow = arrow;
|
||||
arrow->grabMouse();
|
||||
|
||||
for (const auto &item : scene()->selectedItems()) {
|
||||
|
|
|
|||
|
|
@ -13,11 +13,13 @@
|
|||
#include <QList>
|
||||
#include <QPoint>
|
||||
#include <QPointF>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
#include <libcockatrice/game/board/card_state.h>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
class ArrowItem;
|
||||
class CardDragItem;
|
||||
class ServerInfo_Card;
|
||||
class PlayerLogic;
|
||||
|
|
@ -34,6 +36,7 @@ class CardItem : public AbstractCardItem
|
|||
Q_OBJECT
|
||||
private:
|
||||
CardDragItem *dragItem;
|
||||
QPointer<ArrowItem> pendingArrow;
|
||||
|
||||
void prepareDelete();
|
||||
void handleClickedToPlay(bool shiftHeld);
|
||||
|
|
|
|||
|
|
@ -64,13 +64,13 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
|
|||
QString zoneName = zone->getName();
|
||||
|
||||
if (zoneName == ZoneNames::TABLE) {
|
||||
fromStr = tr(" from play");
|
||||
fromStr = tr("from play");
|
||||
} else if (zoneName == ZoneNames::GRAVE) {
|
||||
fromStr = tr(" from their graveyard");
|
||||
fromStr = tr("from their graveyard");
|
||||
} else if (zoneName == ZoneNames::EXILE) {
|
||||
fromStr = tr(" from exile");
|
||||
fromStr = tr("from exile");
|
||||
} else if (zoneName == ZoneNames::HAND) {
|
||||
fromStr = tr(" from their hand");
|
||||
fromStr = tr("from their hand");
|
||||
} else if (zoneName == ZoneNames::DECK) {
|
||||
if (position == 0) {
|
||||
if (cardName.isEmpty()) {
|
||||
|
|
@ -82,9 +82,9 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
|
|||
cardNameContainsStartZone = true;
|
||||
} else {
|
||||
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 {
|
||||
fromStr = tr(" from the top of their library");
|
||||
fromStr = tr("from the top of their library");
|
||||
}
|
||||
}
|
||||
} else if (position == zone->getCards().size()) {
|
||||
|
|
@ -97,29 +97,34 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
|
|||
cardNameContainsStartZone = true;
|
||||
} else {
|
||||
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 {
|
||||
fromStr = tr(" from the bottom of their library");
|
||||
fromStr = tr("from the bottom of their library");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
fromStr = tr(" from their library");
|
||||
fromStr = tr("from their library");
|
||||
}
|
||||
}
|
||||
} else if (zoneName == ZoneNames::SIDEBOARD) {
|
||||
fromStr = tr(" from sideboard");
|
||||
fromStr = tr("from sideboard");
|
||||
} else if (zoneName == ZoneNames::STACK) {
|
||||
fromStr = tr(" from the stack");
|
||||
fromStr = tr("from the stack");
|
||||
} else {
|
||||
fromStr = tr(" from custom zone '%1'").arg(zoneName);
|
||||
fromStr = tr("from custom zone '%1'").arg(zoneName);
|
||||
}
|
||||
|
||||
if (!cardNameContainsStartZone) {
|
||||
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};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts
|
|||
|
||||
aRevealToAll = new QAction(this);
|
||||
|
||||
mCardCounters = new QMenu;
|
||||
mCardCounters = new QMenu(this);
|
||||
|
||||
// Card counters
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
|
|
@ -225,7 +225,7 @@ void CardMenu::createTableMenu(bool canModifyCard)
|
|||
}
|
||||
addSeparator();
|
||||
addAction(aClone);
|
||||
addMenu(new MoveMenu(player));
|
||||
addMenu(new MoveMenu(player, this));
|
||||
addSeparator();
|
||||
addAction(aAttach);
|
||||
if (card->getAttachedTo()) {
|
||||
|
|
@ -233,7 +233,7 @@ void CardMenu::createTableMenu(bool canModifyCard)
|
|||
}
|
||||
addAction(aDrawArrow);
|
||||
addSeparator();
|
||||
addMenu(new PtMenu(player));
|
||||
addMenu(new PtMenu(player, this));
|
||||
addAction(aSetAnnotation);
|
||||
addSeparator();
|
||||
addAction(aReduceLifeByPower);
|
||||
|
|
@ -275,7 +275,7 @@ void CardMenu::createStackMenu(bool canModifyCard)
|
|||
addAction(aPlayFacedown);
|
||||
addSeparator();
|
||||
addAction(aClone);
|
||||
addMenu(new MoveMenu(player));
|
||||
addMenu(new MoveMenu(player, this));
|
||||
addSeparator();
|
||||
addAction(aAttach);
|
||||
addAction(aDrawArrow);
|
||||
|
|
@ -304,7 +304,7 @@ void CardMenu::createGraveyardOrExileMenu(bool canModifyCard)
|
|||
addAction(aPlayFacedown);
|
||||
addSeparator();
|
||||
addAction(aClone);
|
||||
addMenu(new MoveMenu(player));
|
||||
addMenu(new MoveMenu(player, this));
|
||||
addSeparator();
|
||||
addAction(aAttach);
|
||||
addAction(aDrawArrow);
|
||||
|
|
@ -342,7 +342,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard)
|
|||
|
||||
addSeparator();
|
||||
addAction(aClone);
|
||||
addMenu(new MoveMenu(player));
|
||||
addMenu(new MoveMenu(player, this));
|
||||
|
||||
// actions that are really wonky when done from deck or sideboard
|
||||
if (card->getZone()->getName() == ZoneNames::HAND) {
|
||||
|
|
@ -366,7 +366,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard)
|
|||
void CardMenu::createZonelessMenu(bool canModifyCard)
|
||||
{
|
||||
if (canModifyCard) {
|
||||
addMenu(new MoveMenu(player));
|
||||
addMenu(new MoveMenu(player, this));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -415,7 +415,7 @@ void CardMenu::addRelatedCardView()
|
|||
}
|
||||
|
||||
addSeparator();
|
||||
auto viewRelatedCards = new QMenu(tr("View related cards"));
|
||||
auto viewRelatedCards = new QMenu(tr("View related cards"), this);
|
||||
addMenu(viewRelatedCards);
|
||||
for (const CardRelation *relatedCard : relatedCards) {
|
||||
QString relatedCardName = relatedCard->getName();
|
||||
|
|
@ -463,16 +463,16 @@ void CardMenu::addRelatedCardActions()
|
|||
relatedCardName = relatedCard.getName(); // "name"
|
||||
}
|
||||
|
||||
QString text = tr("Token: ");
|
||||
QString text;
|
||||
if (cardRelation->getDoesAttach()) {
|
||||
text +=
|
||||
tr(cardRelation->getDoesTransform() ? "Transform into " : "Attach to ") + "\"" + relatedCardName + "\"";
|
||||
text = cardRelation->getDoesTransform() ? tr("Token: Transform into \"%1\"").arg(relatedCardName)
|
||||
: tr("Token: Attach to \"%1\"").arg(relatedCardName);
|
||||
} else if (cardRelation->getIsVariable()) {
|
||||
text += "X " + relatedCardName;
|
||||
text = tr("Token: X %1").arg(relatedCardName);
|
||||
} else if (cardRelation->getDefaultCount() != 1) {
|
||||
text += QString::number(cardRelation->getDefaultCount()) + "x " + relatedCardName;
|
||||
text = tr("Token: %1x %2").arg(cardRelation->getDefaultCount()).arg(relatedCardName);
|
||||
} else {
|
||||
text += relatedCardName;
|
||||
text = tr("Token: %1").arg(relatedCardName);
|
||||
}
|
||||
|
||||
if (createRelatedCards == nullptr) {
|
||||
|
|
@ -587,4 +587,4 @@ void CardMenu::setShortcutsActive()
|
|||
aRemoveCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aRC" + colorWords[i]));
|
||||
aSetCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aSC" + colorWords[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
#include "../player_graphics_item.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QGraphicsScene>
|
||||
#include <QGraphicsView>
|
||||
#include <QInputDialog>
|
||||
#include <QKeySequence>
|
||||
#include <QList>
|
||||
#include <QMap>
|
||||
|
|
@ -67,6 +70,15 @@ HandMenu::HandMenu(PlayerGraphicsItem *_player, QWidget *parent) : TearOffMenu(p
|
|||
aRevealRandomHandCardToAll->setData(-1);
|
||||
connect(aRevealRandomHandCardToAll, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardTriggered);
|
||||
|
||||
mRevealRandomHandCards = addMenu(QString());
|
||||
mRevealRandomHandCards->setObjectName("revealRandomHandCardsMenu");
|
||||
connect(mRevealRandomHandCards, &QMenu::aboutToShow, this,
|
||||
&HandMenu::populateRevealRandomHandCardsMenuWithActivePlayers);
|
||||
aRevealRandomHandCardsToAll = new QAction(this);
|
||||
aRevealRandomHandCardsToAll->setData(-1);
|
||||
connect(aRevealRandomHandCardsToAll, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardsTriggered);
|
||||
mRevealRandomHandCards->addAction(aRevealRandomHandCardsToAll);
|
||||
|
||||
// We still need to add these actions to menu here so that the shortcuts are active right away
|
||||
mRevealHand->addAction(aRevealHandToAll);
|
||||
mRevealRandomHandCard->addAction(aRevealRandomHandCardToAll);
|
||||
|
|
@ -146,6 +158,8 @@ void HandMenu::retranslateUi()
|
|||
|
||||
mRevealRandomHandCard->setTitle(tr("Reveal r&andom card to..."));
|
||||
aRevealRandomHandCardToAll->setText(tr("All players"));
|
||||
mRevealRandomHandCards->setTitle(tr("Reveal a chosen number of random cards to..."));
|
||||
aRevealRandomHandCardsToAll->setText(tr("All players"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -233,3 +247,51 @@ void HandMenu::onRevealRandomHandCardTriggered()
|
|||
const int targetId = action->data().toInt();
|
||||
player->getLogic()->getPlayerActions()->actRevealRandomHandCard(targetId);
|
||||
}
|
||||
|
||||
void HandMenu::populateRevealRandomHandCardsMenuWithActivePlayers()
|
||||
{
|
||||
const bool hasCards = !player->getLogic()->getHandZone()->getCards().isEmpty();
|
||||
mRevealRandomHandCards->clear();
|
||||
aRevealRandomHandCardsToAll->setEnabled(hasCards);
|
||||
mRevealRandomHandCards->addAction(aRevealRandomHandCardsToAll);
|
||||
mRevealRandomHandCards->addSeparator();
|
||||
const auto &players = player->getLogic()->getGame()->getPlayerManager()->getPlayers().values();
|
||||
for (auto *other : players) {
|
||||
if (other == player->getLogic()) {
|
||||
continue;
|
||||
}
|
||||
auto *action = mRevealRandomHandCards->addAction(other->getPlayerInfo()->getName());
|
||||
action->setData(other->getPlayerInfo()->getId());
|
||||
action->setEnabled(hasCards);
|
||||
connect(action, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardsTriggered);
|
||||
}
|
||||
}
|
||||
|
||||
void HandMenu::onRevealRandomHandCardsTriggered()
|
||||
{
|
||||
const auto *action = qobject_cast<QAction *>(sender());
|
||||
const int handSize = player->getLogic()->getHandZone()->getCards().size();
|
||||
if (!action || handSize == 0) {
|
||||
return;
|
||||
}
|
||||
const int targetId = action->data().toInt();
|
||||
auto *actions = player->getLogic()->getPlayerActions();
|
||||
auto *dialog = new QInputDialog(dialogParent());
|
||||
dialog->setAttribute(Qt::WA_DeleteOnClose);
|
||||
dialog->setWindowTitle(tr("Reveal Random Hand Cards"));
|
||||
dialog->setLabelText(tr("Number of distinct random cards to reveal to %1:").arg(action->text()));
|
||||
dialog->setInputMode(QInputDialog::IntInput);
|
||||
dialog->setIntRange(1, handSize);
|
||||
dialog->setIntValue(qMin(2, handSize));
|
||||
connect(dialog, &QDialog::accepted, actions,
|
||||
[dialog, actions, targetId] { actions->actRevealRandomHandCards(targetId, dialog->intValue()); });
|
||||
dialog->open();
|
||||
}
|
||||
|
||||
QWidget *HandMenu::dialogParent() const
|
||||
{
|
||||
if (auto *view = player->scene() ? player->scene()->views().value(0) : nullptr) {
|
||||
return view->window();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,11 @@ private slots:
|
|||
void populateRevealRandomHandCardMenuWithActivePlayers();
|
||||
void onRevealHandTriggered();
|
||||
void onRevealRandomHandCardTriggered();
|
||||
void populateRevealRandomHandCardsMenuWithActivePlayers();
|
||||
void onRevealRandomHandCardsTriggered();
|
||||
|
||||
private:
|
||||
QWidget *dialogParent() const;
|
||||
PlayerGraphicsItem *player;
|
||||
|
||||
QAction *aViewHand = nullptr;
|
||||
|
|
@ -62,6 +65,9 @@ private:
|
|||
QMenu *mRevealRandomHandCard = nullptr;
|
||||
QAction *aRevealRandomHandCardToAll = nullptr;
|
||||
|
||||
QMenu *mRevealRandomHandCards = nullptr;
|
||||
QAction *aRevealRandomHandCardsToAll = nullptr;
|
||||
|
||||
QMenu *mMoveHandMenu = nullptr;
|
||||
QAction *aMoveHandToTopLibrary = nullptr;
|
||||
QAction *aMoveHandToBottomLibrary = nullptr;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
#include <libcockatrice/game/player/player_actions.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->setData(cmMoveToTopLibrary);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ class MoveMenu : public QMenu
|
|||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MoveMenu(PlayerGraphicsItem *player);
|
||||
explicit MoveMenu(PlayerGraphicsItem *player, QWidget *parent);
|
||||
void setShortcutsActive();
|
||||
void retranslateUi();
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
#include <QMenu>
|
||||
#include <QString>
|
||||
#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_logic.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,
|
||||
&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();
|
||||
|
||||
retranslateUi();
|
||||
|
|
@ -144,6 +150,12 @@ void PlayerMenu::refreshShortcuts()
|
|||
void PlayerMenu::setShortcutsActive()
|
||||
{
|
||||
shortcutsActive = true;
|
||||
|
||||
if (!isGameStarted()) {
|
||||
applyLobbyShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto *c : managedComponents) {
|
||||
c->setShortcutsActive();
|
||||
}
|
||||
|
|
@ -156,5 +168,24 @@ void PlayerMenu::setShortcutsInactive()
|
|||
for (auto *c : managedComponents) {
|
||||
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();
|
||||
}
|
||||
|
|
@ -73,17 +73,24 @@ public:
|
|||
return utilityMenu;
|
||||
}
|
||||
|
||||
/** @brief Whether this player currently holds its shortcuts and the game has started. */
|
||||
[[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();
|
||||
/** @brief Unbind keyboard shortcuts. Called when this player loses focus. */
|
||||
void setShortcutsInactive();
|
||||
|
||||
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;
|
||||
TearOffMenu *playerMenu;
|
||||
QMenu *countersMenu;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
#include <libcockatrice/game/player/player_actions.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();
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ class PtMenu : public QMenu
|
|||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit PtMenu(PlayerGraphicsItem *player);
|
||||
explicit PtMenu(PlayerGraphicsItem *player, QWidget *parent);
|
||||
void retranslateUi();
|
||||
void setShortcutsActive();
|
||||
|
||||
|
|
|
|||
|
|
@ -150,4 +150,13 @@ void UtilityMenu::setShortcutsInactive()
|
|||
aCreateAnotherToken->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:
|
||||
explicit UtilityMenu(PlayerGraphicsItem *player, QMenu *playerMenu);
|
||||
|
||||
/** @brief Binds only the shortcuts that remain usable before the game starts. */
|
||||
void setLobbyShortcutsActive();
|
||||
|
||||
[[nodiscard]] bool createAnotherTokenActionExists() const
|
||||
{
|
||||
return aCreateAnotherToken != nullptr;
|
||||
|
|
|
|||
|
|
@ -1,21 +1,27 @@
|
|||
#include "view_zone.h"
|
||||
|
||||
#include "../../client/settings/cache_settings.h"
|
||||
#include "../../interface/theme_manager.h"
|
||||
#include "../board/card_drag_item.h"
|
||||
#include "../board/card_item.h"
|
||||
#include "../card_dimensions.h"
|
||||
#include "../z_values.h"
|
||||
#include "libcockatrice/card/printing/exact_card.h"
|
||||
#include "libcockatrice/filters/filter_string.h"
|
||||
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h"
|
||||
#include "libcockatrice/utility/card_ref.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QBrush>
|
||||
#include <QColor>
|
||||
#include <QDebug>
|
||||
#include <QFont>
|
||||
#include <QFontMetrics>
|
||||
#include <QMessageLogger>
|
||||
#include <QObject>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#include <QSharedPointer>
|
||||
#include <QtGlobal>
|
||||
#include <QtMath>
|
||||
|
|
@ -34,12 +40,85 @@
|
|||
|
||||
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
|
||||
*/
|
||||
ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent)
|
||||
: 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());
|
||||
if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) {
|
||||
viewLogic->getOriginalZone()->getViews().append(viewLogic);
|
||||
|
|
@ -183,6 +262,8 @@ void ZoneViewZone::reorganizeCards()
|
|||
gridSize = positionCardsForDisplay(cardsToDisplay);
|
||||
}
|
||||
|
||||
updateLibraryEdgeMarker(cardsToDisplay);
|
||||
|
||||
// determine bounding rect
|
||||
qreal aleft = 0;
|
||||
qreal atop = 0;
|
||||
|
|
@ -232,7 +313,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
|
|||
|
||||
lastColumnProp = columnProp;
|
||||
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->setRealZValue(i);
|
||||
longestRow = qMax(row, longestRow);
|
||||
|
|
@ -261,7 +342,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
|
|||
for (int i = 0; i < cardCount; i++) {
|
||||
auto *cItem = qobject_cast<CardItem *>(cards.at(i)->parent());
|
||||
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->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)
|
||||
{
|
||||
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@
|
|||
|
||||
#include "select_zone.h"
|
||||
|
||||
#include <QGraphicsItem>
|
||||
#include <QGraphicsLayoutItem>
|
||||
#include <QList>
|
||||
#include <QLoggingCategory>
|
||||
#include <QPointF>
|
||||
#include <QRectF>
|
||||
#include <QSizeF>
|
||||
#include <QString>
|
||||
|
|
@ -33,6 +35,30 @@ inline Q_LOGGING_CATEGORY(ViewZoneLog, "view_zone");
|
|||
class Response;
|
||||
class ServerInfo_Card;
|
||||
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.
|
||||
|
|
@ -56,6 +82,8 @@ private:
|
|||
FilterString filterString = FilterString("");
|
||||
CardList::SortOption groupBy, sortBy;
|
||||
bool pileView;
|
||||
LibraryEdgeMarker *libraryEdgeMarker;
|
||||
QPointF libraryEdgeMarkerAnchorPos;
|
||||
|
||||
struct GridSize
|
||||
{
|
||||
|
|
@ -64,6 +92,9 @@ private:
|
|||
};
|
||||
|
||||
GridSize positionCardsForDisplay(CardList &cards, CardList::SortOption pileOption = CardList::NoSort);
|
||||
void updateLibraryEdgeMarker(const CardList &displayedCards);
|
||||
void applyLibraryEdgeMarkerText();
|
||||
void positionLibraryEdgeMarker();
|
||||
|
||||
void
|
||||
handleDropEvent(const QList<CardDragItem *> &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override;
|
||||
|
|
@ -79,6 +110,10 @@ public:
|
|||
{
|
||||
return optimumRect;
|
||||
}
|
||||
|
||||
/** @brief Re-applies the translated library edge marker text after a language change. */
|
||||
void retranslateEdgeMarker();
|
||||
|
||||
public slots:
|
||||
void addToViews();
|
||||
void removeFromViews();
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@ void ZoneViewWidget::retranslateUi()
|
|||
|
||||
shuffleCheckBox.setText(tr("shuffle when closing"));
|
||||
pileViewCheckBox.setText(tr("pile view"));
|
||||
zone->retranslateEdgeMarker();
|
||||
}
|
||||
|
||||
void ZoneViewWidget::stopWindowDrag()
|
||||
|
|
|
|||
|
|
@ -1,334 +1,109 @@
|
|||
#include "dlg_create_game.h"
|
||||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../interface/widgets/tabs/tab_room.h"
|
||||
#include "../game_hosting/create_game_form_widget.h"
|
||||
#include "../game_hosting/game_creation_options.h"
|
||||
#include "../tabs/tab_room.h"
|
||||
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||
#include "libcockatrice/protocol/pb/room_commands.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_game.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_user.pb.h"
|
||||
#include "libcockatrice/utility/macros.h"
|
||||
#include "libcockatrice/protocol/pending_command.h"
|
||||
#include "libcockatrice/settings/game_settings.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QDebug>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QGridLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QMapIterator>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QRadioButton>
|
||||
#include <QSet>
|
||||
#include <QSharedPointer>
|
||||
#include <QSize>
|
||||
#include <QSpinBox>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
#include <libcockatrice/settings/game_settings.h>
|
||||
#include <libcockatrice/utility/string_limits.h>
|
||||
#include <qnamespace.h>
|
||||
#include <string>
|
||||
|
||||
class QWidget;
|
||||
|
||||
void DlgCreateGame::sharedCtor()
|
||||
{
|
||||
rememberGameSettings = new QCheckBox(tr("Re&member settings"));
|
||||
descriptionLabel = new QLabel(tr("&Description:"));
|
||||
descriptionEdit = new QLineEdit;
|
||||
descriptionEdit->setMaxLength(MAX_NAME_LENGTH);
|
||||
descriptionLabel->setBuddy(descriptionEdit);
|
||||
|
||||
maxPlayersLabel = new QLabel(tr("P&layers:"));
|
||||
maxPlayersEdit = new QSpinBox();
|
||||
maxPlayersEdit->setMinimum(1);
|
||||
maxPlayersEdit->setMaximum(100);
|
||||
maxPlayersEdit->setValue(2);
|
||||
maxPlayersLabel->setBuddy(maxPlayersEdit);
|
||||
|
||||
auto *generalGrid = new QGridLayout;
|
||||
generalGrid->addWidget(descriptionLabel, 0, 0);
|
||||
generalGrid->addWidget(descriptionEdit, 0, 1);
|
||||
generalGrid->addWidget(maxPlayersLabel, 1, 0);
|
||||
generalGrid->addWidget(maxPlayersEdit, 1, 1);
|
||||
generalGroupBox = new QGroupBox(tr("General"));
|
||||
generalGroupBox->setLayout(generalGrid);
|
||||
|
||||
auto *gameTypeLayout = new QVBoxLayout;
|
||||
QMapIterator<int, QString> gameTypeIterator(gameTypes);
|
||||
while (gameTypeIterator.hasNext()) {
|
||||
gameTypeIterator.next();
|
||||
auto *gameTypeRadioButton = new QRadioButton(gameTypeIterator.value(), this);
|
||||
gameTypeLayout->addWidget(gameTypeRadioButton);
|
||||
gameTypeCheckBoxes.insert(gameTypeIterator.key(), gameTypeRadioButton);
|
||||
bool isChecked = SettingsCache::instance().game().getGameTypes().contains(gameTypeIterator.value() + ", ");
|
||||
gameTypeCheckBoxes[gameTypeIterator.key()]->setChecked(isChecked);
|
||||
}
|
||||
auto *gameTypeGroupBox = new QGroupBox(tr("Game type"));
|
||||
gameTypeGroupBox->setLayout(gameTypeLayout);
|
||||
|
||||
passwordLabel = new QLabel(tr("&Password:"));
|
||||
passwordEdit = new QLineEdit;
|
||||
passwordEdit->setMaxLength(MAX_NAME_LENGTH);
|
||||
passwordLabel->setBuddy(passwordEdit);
|
||||
|
||||
onlyBuddiesCheckBox = new QCheckBox(tr("Only &buddies can join"));
|
||||
onlyRegisteredCheckBox = new QCheckBox(tr("Only ®istered users can join"));
|
||||
if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) {
|
||||
onlyRegisteredCheckBox->setChecked(true);
|
||||
} else {
|
||||
onlyBuddiesCheckBox->setEnabled(false);
|
||||
onlyRegisteredCheckBox->setEnabled(false);
|
||||
}
|
||||
|
||||
auto *joinRestrictionsLayout = new QGridLayout;
|
||||
joinRestrictionsLayout->addWidget(passwordLabel, 0, 0);
|
||||
joinRestrictionsLayout->addWidget(passwordEdit, 0, 1);
|
||||
joinRestrictionsLayout->addWidget(onlyBuddiesCheckBox, 1, 0, 1, 2);
|
||||
joinRestrictionsLayout->addWidget(onlyRegisteredCheckBox, 2, 0, 1, 2);
|
||||
|
||||
auto *joinRestrictionsGroupBox = new QGroupBox(tr("Joining restrictions"));
|
||||
joinRestrictionsGroupBox->setLayout(joinRestrictionsLayout);
|
||||
|
||||
spectatorsAllowedCheckBox = new QCheckBox(tr("&Spectators can watch"));
|
||||
spectatorsAllowedCheckBox->setChecked(true);
|
||||
connect(spectatorsAllowedCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &DlgCreateGame::spectatorsAllowedChanged);
|
||||
spectatorsNeedPasswordCheckBox = new QCheckBox(tr("Spectators &need a password to watch"));
|
||||
spectatorsCanTalkCheckBox = new QCheckBox(tr("Spectators can &chat"));
|
||||
spectatorsSeeEverythingCheckBox = new QCheckBox(tr("Spectators can see &hands"));
|
||||
createGameAsSpectatorCheckBox = new QCheckBox(tr("Create game as spectator"));
|
||||
auto *spectatorsLayout = new QVBoxLayout;
|
||||
spectatorsLayout->addWidget(spectatorsAllowedCheckBox);
|
||||
spectatorsLayout->addWidget(spectatorsNeedPasswordCheckBox);
|
||||
spectatorsLayout->addWidget(spectatorsCanTalkCheckBox);
|
||||
spectatorsLayout->addWidget(spectatorsSeeEverythingCheckBox);
|
||||
spectatorsLayout->addWidget(createGameAsSpectatorCheckBox);
|
||||
spectatorsGroupBox = new QGroupBox(tr("Spectators"));
|
||||
spectatorsGroupBox->setLayout(spectatorsLayout);
|
||||
|
||||
startingLifeTotalLabel = new QLabel(tr("Starting life total:"));
|
||||
startingLifeTotalEdit = new QSpinBox();
|
||||
startingLifeTotalEdit->setMinimum(1);
|
||||
startingLifeTotalEdit->setMaximum(99999); ///< Arbitrary but we can raise this when people start complaining.
|
||||
startingLifeTotalEdit->setValue(20);
|
||||
startingLifeTotalLabel->setBuddy(startingLifeTotalEdit);
|
||||
|
||||
shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby"));
|
||||
|
||||
shufflePlayersCheckBox = new QCheckBox(tr("Randomize turn order (seats) on each game start"));
|
||||
|
||||
createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge"));
|
||||
|
||||
auto *gameSetupOptionsLayout = new QGridLayout;
|
||||
gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0);
|
||||
gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1);
|
||||
gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2);
|
||||
gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2);
|
||||
if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) {
|
||||
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2);
|
||||
} else {
|
||||
createGameAsJudgeCheckBox->setChecked(false);
|
||||
createGameAsJudgeCheckBox->setHidden(true);
|
||||
}
|
||||
gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options"));
|
||||
gameSetupOptionsGroupBox->setLayout(gameSetupOptionsLayout);
|
||||
|
||||
auto *grid = new QGridLayout;
|
||||
|
||||
// Top row
|
||||
grid->addWidget(generalGroupBox, 0, 0);
|
||||
grid->addWidget(joinRestrictionsGroupBox, 0, 1);
|
||||
|
||||
// Middle row: left column
|
||||
grid->addWidget(gameTypeGroupBox, 1, 0);
|
||||
|
||||
// Middle row: right column (game setup + spectators)
|
||||
auto *rightLayout = new QVBoxLayout;
|
||||
rightLayout->addWidget(spectatorsGroupBox, Qt::AlignTop); // top
|
||||
rightLayout->addWidget(gameSetupOptionsGroupBox); // bottom
|
||||
|
||||
grid->addLayout(rightLayout, 1, 1);
|
||||
|
||||
// Bottom row
|
||||
grid->addWidget(rememberGameSettings, 3, 0, 1, 2); // span both columns if needed
|
||||
|
||||
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgCreateGame::reject);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout;
|
||||
mainLayout->addLayout(grid);
|
||||
mainLayout->addWidget(buttonBox);
|
||||
|
||||
setLayout(mainLayout);
|
||||
|
||||
setFixedHeight(sizeHint().height());
|
||||
}
|
||||
|
||||
DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap<int, QString> &_gameTypes, QWidget *parent)
|
||||
: QDialog(parent), room(_room), gameTypes(_gameTypes)
|
||||
: QDialog(parent), room(_room)
|
||||
{
|
||||
sharedCtor();
|
||||
const bool registered = room && (room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered);
|
||||
const bool judge = room && (room->getUserInfo()->user_level() & ServerInfo_User::IsJudge);
|
||||
|
||||
rememberGameSettings->setChecked(SettingsCache::instance().game().getRememberGameSettings());
|
||||
descriptionEdit->setText(SettingsCache::instance().game().getGameDescription());
|
||||
maxPlayersEdit->setValue(SettingsCache::instance().game().getMaxPlayers());
|
||||
if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) {
|
||||
onlyBuddiesCheckBox->setChecked(SettingsCache::instance().game().getOnlyBuddies());
|
||||
onlyRegisteredCheckBox->setChecked(SettingsCache::instance().game().getOnlyRegistered());
|
||||
form = new CreateGameFormWidget(_gameTypes, registered, judge, this);
|
||||
const bool rememberSettings = SettingsCache::instance().game().getRememberGameSettings();
|
||||
form->setRememberSettingsChecked(rememberSettings);
|
||||
if (rememberSettings) {
|
||||
form->setOptions(GameCreationOptions::fromSettings(SettingsCache::instance().game(), _gameTypes, registered));
|
||||
} else {
|
||||
onlyBuddiesCheckBox->setEnabled(false);
|
||||
onlyRegisteredCheckBox->setEnabled(false);
|
||||
form->resetToDefaults();
|
||||
}
|
||||
spectatorsAllowedCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsAllowed());
|
||||
spectatorsNeedPasswordCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsNeedPassword());
|
||||
spectatorsCanTalkCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsCanTalk());
|
||||
spectatorsSeeEverythingCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsCanSeeEverything());
|
||||
createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().game().getCreateGameAsSpectator());
|
||||
startingLifeTotalEdit->setValue(SettingsCache::instance().game().getDefaultStartingLifeTotal());
|
||||
shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().game().getShareDecklistsOnLoad());
|
||||
shufflePlayersCheckBox->setChecked(SettingsCache::instance().game().getShufflePlayers());
|
||||
form->focusDescription();
|
||||
|
||||
if (!rememberGameSettings->isChecked()) {
|
||||
actReset();
|
||||
}
|
||||
errorLabel = new QLabel(this);
|
||||
errorLabel->setObjectName("createGameErrorLabel");
|
||||
errorLabel->setWordWrap(true);
|
||||
errorLabel->setVisible(false);
|
||||
|
||||
descriptionEdit->setFocus();
|
||||
clearButton = new QPushButton(tr("&Clear"));
|
||||
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok, this);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgCreateGame::reject);
|
||||
clearButton = new QPushButton(tr("&Clear"), this);
|
||||
buttonBox->addButton(QDialogButtonBox::Cancel);
|
||||
buttonBox->addButton(clearButton, QDialogButtonBox::ActionRole);
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::actOK);
|
||||
connect(clearButton, &QPushButton::clicked, this, &DlgCreateGame::actReset);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout;
|
||||
mainLayout->addWidget(form);
|
||||
mainLayout->addWidget(errorLabel);
|
||||
mainLayout->addWidget(buttonBox);
|
||||
|
||||
setLayout(mainLayout);
|
||||
setWindowTitle(tr("Create game"));
|
||||
}
|
||||
|
||||
DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QString> &_gameTypes, QWidget *parent)
|
||||
: QDialog(parent), room(0), gameTypes(_gameTypes)
|
||||
: QDialog(parent), room(nullptr)
|
||||
{
|
||||
sharedCtor();
|
||||
|
||||
rememberGameSettings->setEnabled(false);
|
||||
descriptionEdit->setEnabled(false);
|
||||
maxPlayersEdit->setEnabled(false);
|
||||
passwordEdit->setEnabled(false);
|
||||
onlyBuddiesCheckBox->setEnabled(false);
|
||||
onlyRegisteredCheckBox->setEnabled(false);
|
||||
spectatorsAllowedCheckBox->setEnabled(false);
|
||||
spectatorsNeedPasswordCheckBox->setEnabled(false);
|
||||
spectatorsCanTalkCheckBox->setEnabled(false);
|
||||
spectatorsSeeEverythingCheckBox->setEnabled(false);
|
||||
createGameAsSpectatorCheckBox->setEnabled(false);
|
||||
startingLifeTotalEdit->setEnabled(false);
|
||||
shareDecklistsOnLoadCheckBox->setEnabled(false);
|
||||
shufflePlayersCheckBox->setEnabled(false);
|
||||
|
||||
descriptionEdit->setText(QString::fromStdString(gameInfo.description()));
|
||||
maxPlayersEdit->setValue(gameInfo.max_players());
|
||||
onlyBuddiesCheckBox->setChecked(gameInfo.only_buddies());
|
||||
onlyRegisteredCheckBox->setChecked(gameInfo.only_registered());
|
||||
spectatorsAllowedCheckBox->setChecked(gameInfo.spectators_allowed());
|
||||
spectatorsNeedPasswordCheckBox->setChecked(gameInfo.spectators_need_password());
|
||||
spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat());
|
||||
spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient());
|
||||
shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load());
|
||||
shufflePlayersCheckBox->setChecked(gameInfo.shuffle_players());
|
||||
|
||||
QSet<int> types;
|
||||
for (int i = 0; i < gameInfo.game_types_size(); ++i) {
|
||||
types.insert(gameInfo.game_types(i));
|
||||
}
|
||||
|
||||
QMapIterator<int, QString> gameTypeIterator(gameTypes);
|
||||
while (gameTypeIterator.hasNext()) {
|
||||
gameTypeIterator.next();
|
||||
|
||||
QRadioButton *gameTypeCheckBox = gameTypeCheckBoxes.value(gameTypeIterator.key());
|
||||
gameTypeCheckBox->setEnabled(false);
|
||||
gameTypeCheckBox->setChecked(types.contains(gameTypeIterator.key()));
|
||||
}
|
||||
form = new CreateGameFormWidget(_gameTypes, false, false, this);
|
||||
form->setOptions(GameCreationOptions::fromGameInfo(gameInfo, _gameTypes));
|
||||
form->setReadOnly(true);
|
||||
|
||||
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok, this);
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::accept);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout;
|
||||
mainLayout->addWidget(form);
|
||||
mainLayout->addWidget(buttonBox);
|
||||
|
||||
setLayout(mainLayout);
|
||||
setWindowTitle(tr("Game information"));
|
||||
}
|
||||
|
||||
void DlgCreateGame::actReset()
|
||||
{
|
||||
descriptionEdit->setText("");
|
||||
maxPlayersEdit->setValue(2);
|
||||
|
||||
passwordEdit->setText("");
|
||||
onlyBuddiesCheckBox->setChecked(false);
|
||||
onlyRegisteredCheckBox->setChecked(room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered);
|
||||
|
||||
spectatorsAllowedCheckBox->setChecked(true);
|
||||
spectatorsNeedPasswordCheckBox->setChecked(false);
|
||||
spectatorsCanTalkCheckBox->setChecked(false);
|
||||
spectatorsSeeEverythingCheckBox->setChecked(false);
|
||||
createGameAsSpectatorCheckBox->setChecked(false);
|
||||
|
||||
startingLifeTotalEdit->setValue(20);
|
||||
shareDecklistsOnLoadCheckBox->setChecked(false);
|
||||
shufflePlayersCheckBox->setChecked(false);
|
||||
createGameAsJudgeCheckBox->setChecked(false);
|
||||
|
||||
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
|
||||
while (gameTypeCheckBoxIterator.hasNext()) {
|
||||
gameTypeCheckBoxIterator.next();
|
||||
// must set auto enclusive to false to be able to set the check to false
|
||||
gameTypeCheckBoxIterator.value()->setAutoExclusive(false);
|
||||
gameTypeCheckBoxIterator.value()->setChecked(false);
|
||||
gameTypeCheckBoxIterator.value()->setAutoExclusive(true);
|
||||
}
|
||||
|
||||
descriptionEdit->setFocus();
|
||||
form->resetToDefaults();
|
||||
clearError();
|
||||
}
|
||||
|
||||
void DlgCreateGame::actOK()
|
||||
{
|
||||
Command_CreateGame cmd;
|
||||
cmd.set_description(descriptionEdit->text().simplified().toStdString());
|
||||
cmd.set_password(passwordEdit->text().toStdString());
|
||||
cmd.set_max_players(maxPlayersEdit->value());
|
||||
cmd.set_only_buddies(onlyBuddiesCheckBox->isChecked());
|
||||
cmd.set_only_registered(onlyRegisteredCheckBox->isChecked());
|
||||
cmd.set_spectators_allowed(spectatorsAllowedCheckBox->isChecked());
|
||||
cmd.set_spectators_need_password(spectatorsNeedPasswordCheckBox->isChecked());
|
||||
cmd.set_spectators_can_talk(spectatorsCanTalkCheckBox->isChecked());
|
||||
cmd.set_spectators_see_everything(spectatorsSeeEverythingCheckBox->isChecked());
|
||||
cmd.set_join_as_judge(createGameAsJudgeCheckBox->isChecked());
|
||||
cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked());
|
||||
cmd.set_starting_life_total(startingLifeTotalEdit->value());
|
||||
cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked());
|
||||
cmd.set_shuffle_players(shufflePlayersCheckBox->isChecked());
|
||||
clearError();
|
||||
|
||||
auto _gameTypes = QString();
|
||||
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
|
||||
while (gameTypeCheckBoxIterator.hasNext()) {
|
||||
gameTypeCheckBoxIterator.next();
|
||||
if (gameTypeCheckBoxIterator.value()->isChecked()) {
|
||||
cmd.add_game_type_ids(gameTypeCheckBoxIterator.key());
|
||||
_gameTypes += gameTypeCheckBoxIterator.value()->text() + ", ";
|
||||
}
|
||||
const GameCreationOptions options = form->options();
|
||||
Command_CreateGame cmd = options.toCreateGameCommand();
|
||||
|
||||
const bool rememberSettings = form->isRememberSettingsChecked();
|
||||
auto &gameSettings = SettingsCache::instance().game();
|
||||
gameSettings.setRememberGameSettings(rememberSettings);
|
||||
if (rememberSettings) {
|
||||
gameSettings.setGameDescription(options.description);
|
||||
gameSettings.setMaxPlayers(options.maxPlayers);
|
||||
gameSettings.setOnlyBuddies(options.onlyBuddies);
|
||||
gameSettings.setOnlyRegistered(options.onlyRegistered);
|
||||
gameSettings.setSpectatorsAllowed(options.spectatorsAllowed);
|
||||
gameSettings.setSpectatorsNeedPassword(options.spectatorsNeedPassword);
|
||||
gameSettings.setSpectatorsCanTalk(options.spectatorsCanTalk);
|
||||
gameSettings.setSpectatorsCanSeeEverything(options.spectatorsSeeEverything);
|
||||
gameSettings.setCreateGameAsSpectator(options.joinAsSpectator);
|
||||
gameSettings.setDefaultStartingLifeTotal(options.startingLifeTotal);
|
||||
gameSettings.setShareDecklistsOnLoad(options.shareDecklistsOnLoad);
|
||||
gameSettings.setShufflePlayers(options.shufflePlayers);
|
||||
gameSettings.setGameTypes(form->selectedGameTypesSettingString());
|
||||
}
|
||||
|
||||
SettingsCache::instance().game().setRememberGameSettings(rememberGameSettings->isChecked());
|
||||
if (rememberGameSettings->isChecked()) {
|
||||
SettingsCache::instance().game().setGameDescription(descriptionEdit->text());
|
||||
SettingsCache::instance().game().setMaxPlayers(maxPlayersEdit->value());
|
||||
SettingsCache::instance().game().setOnlyBuddies(onlyBuddiesCheckBox->isChecked());
|
||||
SettingsCache::instance().game().setOnlyRegistered(onlyRegisteredCheckBox->isChecked());
|
||||
SettingsCache::instance().game().setSpectatorsAllowed(spectatorsAllowedCheckBox->isChecked());
|
||||
SettingsCache::instance().game().setSpectatorsNeedPassword(spectatorsNeedPasswordCheckBox->isChecked());
|
||||
SettingsCache::instance().game().setSpectatorsCanTalk(spectatorsCanTalkCheckBox->isChecked());
|
||||
SettingsCache::instance().game().setSpectatorsCanSeeEverything(spectatorsSeeEverythingCheckBox->isChecked());
|
||||
SettingsCache::instance().game().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked());
|
||||
SettingsCache::instance().game().setDefaultStartingLifeTotal(startingLifeTotalEdit->value());
|
||||
SettingsCache::instance().game().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked());
|
||||
SettingsCache::instance().game().setShufflePlayers(shufflePlayersCheckBox->isChecked());
|
||||
SettingsCache::instance().game().setGameTypes(_gameTypes);
|
||||
}
|
||||
PendingCommand *pend = room->prepareRoomCommand(cmd);
|
||||
connect(pend, &PendingCommand::finished, this, &DlgCreateGame::checkResponse);
|
||||
room->sendRoomCommand(pend);
|
||||
|
|
@ -343,14 +118,26 @@ void DlgCreateGame::checkResponse(const Response &response)
|
|||
if (response.response_code() == Response::RespOk) {
|
||||
accept();
|
||||
} else {
|
||||
QMessageBox::critical(this, tr("Error"), tr("Server error."));
|
||||
return;
|
||||
showError(tr("Server error."));
|
||||
}
|
||||
}
|
||||
|
||||
void DlgCreateGame::spectatorsAllowedChanged(QT_STATE_CHANGED_T state)
|
||||
void DlgCreateGame::showError(const QString &message)
|
||||
{
|
||||
spectatorsNeedPasswordCheckBox->setEnabled(state);
|
||||
spectatorsCanTalkCheckBox->setEnabled(state);
|
||||
spectatorsSeeEverythingCheckBox->setEnabled(state);
|
||||
if (!errorLabel) {
|
||||
return;
|
||||
}
|
||||
errorLabel->setText(message);
|
||||
errorLabel->setVisible(true);
|
||||
adjustSize();
|
||||
}
|
||||
|
||||
void DlgCreateGame::clearError()
|
||||
{
|
||||
if (!errorLabel) {
|
||||
return;
|
||||
}
|
||||
errorLabel->clear();
|
||||
errorLabel->setVisible(false);
|
||||
adjustSize();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
* @file dlg_create_game.h
|
||||
* @ingroup RoomDialogs
|
||||
*/
|
||||
//! \todo Document this file.
|
||||
|
||||
#ifndef DLG_CREATEGAME_H
|
||||
#define DLG_CREATEGAME_H
|
||||
|
|
@ -10,21 +9,15 @@
|
|||
#include <QDialog>
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
#include <libcockatrice/utility/macros.h>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
class QCheckBox;
|
||||
class CreateGameFormWidget;
|
||||
class QDialogButtonBox;
|
||||
class QGroupBox;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QPushButton;
|
||||
class QRadioButton;
|
||||
class QSpinBox;
|
||||
class Response;
|
||||
class ServerInfo_Game;
|
||||
class TabRoom;
|
||||
class QWidget;
|
||||
|
||||
class DlgCreateGame : public QDialog
|
||||
{
|
||||
|
|
@ -36,27 +29,16 @@ private slots:
|
|||
void actOK();
|
||||
void actReset();
|
||||
void checkResponse(const Response &response);
|
||||
void spectatorsAllowedChanged(QT_STATE_CHANGED_T state);
|
||||
|
||||
private:
|
||||
TabRoom *room;
|
||||
QMap<int, QString> gameTypes;
|
||||
QMap<int, QRadioButton *> gameTypeCheckBoxes;
|
||||
CreateGameFormWidget *form = nullptr;
|
||||
QDialogButtonBox *buttonBox = nullptr;
|
||||
QPushButton *clearButton = nullptr;
|
||||
QLabel *errorLabel = nullptr;
|
||||
|
||||
QGroupBox *generalGroupBox, *spectatorsGroupBox, *gameSetupOptionsGroupBox;
|
||||
QLabel *descriptionLabel, *passwordLabel, *maxPlayersLabel, *startingLifeTotalLabel, *shareDecklistsOnLoadLabel;
|
||||
QLineEdit *descriptionEdit, *passwordEdit;
|
||||
QSpinBox *maxPlayersEdit, *startingLifeTotalEdit;
|
||||
QCheckBox *onlyBuddiesCheckBox, *onlyRegisteredCheckBox;
|
||||
QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox,
|
||||
*spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox;
|
||||
QCheckBox *shareDecklistsOnLoadCheckBox;
|
||||
QCheckBox *shufflePlayersCheckBox;
|
||||
QDialogButtonBox *buttonBox;
|
||||
QPushButton *clearButton;
|
||||
QCheckBox *rememberGameSettings;
|
||||
|
||||
void sharedCtor();
|
||||
void showError(const QString &message);
|
||||
void clearError();
|
||||
};
|
||||
|
||||
#endif
|
||||
#endif // DLG_CREATEGAME_H
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ DlgForgotPasswordReset::DlgForgotPasswordReset(QWidget *parent) : QDialog(parent
|
|||
playernameEdit->setMaxLength(MAX_NAME_LENGTH);
|
||||
playernameLabel->setBuddy(playernameEdit);
|
||||
|
||||
//: Code mailed to the player so they can reset their password. Not a game token.
|
||||
tokenLabel = new QLabel(tr("Token:"));
|
||||
tokenEdit = new QLineEdit();
|
||||
tokenEdit->setMaxLength(MAX_NAME_LENGTH);
|
||||
|
|
|
|||
|
|
@ -230,7 +230,8 @@ void DlgMyReports::onSelectionChanged()
|
|||
connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse);
|
||||
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");
|
||||
commentButton->setEnabled(canComment);
|
||||
commentInput->setEnabled(canComment);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,260 @@
|
|||
#include "create_game_form_widget.h"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QGridLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QRadioButton>
|
||||
#include <QSpinBox>
|
||||
#include <QVBoxLayout>
|
||||
#include <libcockatrice/utility/string_limits.h>
|
||||
#include <qnamespace.h>
|
||||
|
||||
CreateGameFormWidget::CreateGameFormWidget(const QMap<int, QString> &gameTypes,
|
||||
bool _registeredUser,
|
||||
bool judge,
|
||||
QWidget *parent)
|
||||
: QWidget(parent), registeredUser(_registeredUser)
|
||||
{
|
||||
setObjectName("createGameForm");
|
||||
|
||||
rememberGameSettings = new QCheckBox(tr("Re&member settings"));
|
||||
auto *descriptionLabel = new QLabel(tr("&Description:"));
|
||||
descriptionEdit = new QLineEdit;
|
||||
descriptionEdit->setMaxLength(MAX_NAME_LENGTH);
|
||||
descriptionLabel->setBuddy(descriptionEdit);
|
||||
|
||||
auto *maxPlayersLabel = new QLabel(tr("P&layers:"));
|
||||
maxPlayersEdit = new QSpinBox();
|
||||
maxPlayersEdit->setMinimum(1);
|
||||
maxPlayersEdit->setMaximum(100);
|
||||
maxPlayersEdit->setValue(2);
|
||||
maxPlayersLabel->setBuddy(maxPlayersEdit);
|
||||
|
||||
auto *generalGrid = new QGridLayout;
|
||||
generalGrid->addWidget(descriptionLabel, 0, 0);
|
||||
generalGrid->addWidget(descriptionEdit, 0, 1);
|
||||
generalGrid->addWidget(maxPlayersLabel, 1, 0);
|
||||
generalGrid->addWidget(maxPlayersEdit, 1, 1);
|
||||
auto *generalGroupBox = new QGroupBox(tr("General"));
|
||||
generalGroupBox->setLayout(generalGrid);
|
||||
|
||||
auto *gameTypeLayout = new QVBoxLayout;
|
||||
for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) {
|
||||
auto *gameTypeRadioButton = new QRadioButton(it.value(), this);
|
||||
gameTypeLayout->addWidget(gameTypeRadioButton);
|
||||
gameTypeCheckBoxes.insert(it.key(), gameTypeRadioButton);
|
||||
}
|
||||
auto *gameTypeGroupBox = new QGroupBox(tr("Game type"));
|
||||
gameTypeGroupBox->setLayout(gameTypeLayout);
|
||||
|
||||
auto *passwordLabel = new QLabel(tr("&Password:"));
|
||||
passwordEdit = new QLineEdit;
|
||||
passwordEdit->setMaxLength(MAX_NAME_LENGTH);
|
||||
passwordLabel->setBuddy(passwordEdit);
|
||||
|
||||
onlyBuddiesCheckBox = new QCheckBox(tr("Only &buddies can join"));
|
||||
onlyRegisteredCheckBox = new QCheckBox(tr("Only ®istered users can join"));
|
||||
if (registeredUser) {
|
||||
onlyRegisteredCheckBox->setChecked(true);
|
||||
} else {
|
||||
onlyBuddiesCheckBox->setEnabled(false);
|
||||
onlyRegisteredCheckBox->setEnabled(false);
|
||||
}
|
||||
|
||||
auto *joinRestrictionsLayout = new QGridLayout;
|
||||
joinRestrictionsLayout->addWidget(passwordLabel, 0, 0);
|
||||
joinRestrictionsLayout->addWidget(passwordEdit, 0, 1);
|
||||
joinRestrictionsLayout->addWidget(onlyBuddiesCheckBox, 1, 0, 1, 2);
|
||||
joinRestrictionsLayout->addWidget(onlyRegisteredCheckBox, 2, 0, 1, 2);
|
||||
|
||||
auto *joinRestrictionsGroupBox = new QGroupBox(tr("Joining restrictions"));
|
||||
joinRestrictionsGroupBox->setLayout(joinRestrictionsLayout);
|
||||
|
||||
spectatorsAllowedCheckBox = new QCheckBox(tr("&Spectators can watch"));
|
||||
spectatorsAllowedCheckBox->setChecked(true);
|
||||
connect(spectatorsAllowedCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
|
||||
&CreateGameFormWidget::spectatorsAllowedChanged);
|
||||
spectatorsNeedPasswordCheckBox = new QCheckBox(tr("Spectators &need a password to watch"));
|
||||
spectatorsCanTalkCheckBox = new QCheckBox(tr("Spectators can &chat"));
|
||||
spectatorsSeeEverythingCheckBox = new QCheckBox(tr("Spectators can see &hands"));
|
||||
createGameAsSpectatorCheckBox = new QCheckBox(tr("Create game as spectator"));
|
||||
auto *spectatorsLayout = new QVBoxLayout;
|
||||
spectatorsLayout->addWidget(spectatorsAllowedCheckBox);
|
||||
spectatorsLayout->addWidget(spectatorsNeedPasswordCheckBox);
|
||||
spectatorsLayout->addWidget(spectatorsCanTalkCheckBox);
|
||||
spectatorsLayout->addWidget(spectatorsSeeEverythingCheckBox);
|
||||
spectatorsLayout->addWidget(createGameAsSpectatorCheckBox);
|
||||
auto *spectatorsGroupBox = new QGroupBox(tr("Spectators"));
|
||||
spectatorsGroupBox->setLayout(spectatorsLayout);
|
||||
|
||||
auto *startingLifeTotalLabel = new QLabel(tr("Starting life total:"));
|
||||
startingLifeTotalEdit = new QSpinBox();
|
||||
startingLifeTotalEdit->setMinimum(1);
|
||||
startingLifeTotalEdit->setMaximum(99999); ///< Arbitrary but we can raise this when people start complaining.
|
||||
startingLifeTotalEdit->setValue(20);
|
||||
startingLifeTotalLabel->setBuddy(startingLifeTotalEdit);
|
||||
|
||||
shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby"));
|
||||
|
||||
shufflePlayersCheckBox = new QCheckBox(tr("Randomize turn order (seats) on each game start"));
|
||||
|
||||
createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge"));
|
||||
|
||||
auto *gameSetupOptionsLayout = new QGridLayout;
|
||||
gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0);
|
||||
gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1);
|
||||
gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2);
|
||||
gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2);
|
||||
if (judge) {
|
||||
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2);
|
||||
} else {
|
||||
createGameAsJudgeCheckBox->setChecked(false);
|
||||
createGameAsJudgeCheckBox->setHidden(true);
|
||||
}
|
||||
auto *gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options"));
|
||||
gameSetupOptionsGroupBox->setLayout(gameSetupOptionsLayout);
|
||||
|
||||
auto *grid = new QGridLayout;
|
||||
|
||||
// Top row
|
||||
grid->addWidget(generalGroupBox, 0, 0);
|
||||
grid->addWidget(joinRestrictionsGroupBox, 0, 1);
|
||||
|
||||
// Middle row: left column
|
||||
grid->addWidget(gameTypeGroupBox, 1, 0);
|
||||
|
||||
// Middle row: right column (game setup + spectators)
|
||||
auto *rightLayout = new QVBoxLayout;
|
||||
rightLayout->addWidget(spectatorsGroupBox, Qt::AlignTop); // top
|
||||
rightLayout->addWidget(gameSetupOptionsGroupBox); // bottom
|
||||
|
||||
grid->addLayout(rightLayout, 1, 1);
|
||||
|
||||
// Bottom row
|
||||
grid->addWidget(rememberGameSettings, 3, 0, 1, 2); // span both columns if needed
|
||||
|
||||
setLayout(grid);
|
||||
}
|
||||
|
||||
void CreateGameFormWidget::setOptions(const GameCreationOptions &options)
|
||||
{
|
||||
descriptionEdit->setText(options.description);
|
||||
maxPlayersEdit->setValue(options.maxPlayers);
|
||||
passwordEdit->setText(options.password);
|
||||
onlyBuddiesCheckBox->setChecked(options.onlyBuddies);
|
||||
onlyRegisteredCheckBox->setChecked(options.onlyRegistered);
|
||||
spectatorsAllowedCheckBox->setChecked(options.spectatorsAllowed);
|
||||
spectatorsNeedPasswordCheckBox->setChecked(options.spectatorsNeedPassword);
|
||||
spectatorsCanTalkCheckBox->setChecked(options.spectatorsCanTalk);
|
||||
spectatorsSeeEverythingCheckBox->setChecked(options.spectatorsSeeEverything);
|
||||
createGameAsJudgeCheckBox->setChecked(options.joinAsJudge);
|
||||
createGameAsSpectatorCheckBox->setChecked(options.joinAsSpectator);
|
||||
startingLifeTotalEdit->setValue(options.startingLifeTotal);
|
||||
shareDecklistsOnLoadCheckBox->setChecked(options.shareDecklistsOnLoad);
|
||||
shufflePlayersCheckBox->setChecked(options.shufflePlayers);
|
||||
|
||||
for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) {
|
||||
it.value()->setChecked(options.gameTypeIds.contains(it.key()));
|
||||
}
|
||||
}
|
||||
|
||||
GameCreationOptions CreateGameFormWidget::options() const
|
||||
{
|
||||
GameCreationOptions options;
|
||||
options.description = descriptionEdit->text();
|
||||
options.password = passwordEdit->text();
|
||||
options.maxPlayers = maxPlayersEdit->value();
|
||||
options.onlyBuddies = onlyBuddiesCheckBox->isChecked();
|
||||
options.onlyRegistered = onlyRegisteredCheckBox->isChecked();
|
||||
options.spectatorsAllowed = spectatorsAllowedCheckBox->isChecked();
|
||||
options.spectatorsNeedPassword = spectatorsNeedPasswordCheckBox->isChecked();
|
||||
options.spectatorsCanTalk = spectatorsCanTalkCheckBox->isChecked();
|
||||
options.spectatorsSeeEverything = spectatorsSeeEverythingCheckBox->isChecked();
|
||||
options.joinAsJudge = createGameAsJudgeCheckBox->isChecked();
|
||||
options.joinAsSpectator = createGameAsSpectatorCheckBox->isChecked();
|
||||
options.startingLifeTotal = startingLifeTotalEdit->value();
|
||||
options.shareDecklistsOnLoad = shareDecklistsOnLoadCheckBox->isChecked();
|
||||
options.shufflePlayers = shufflePlayersCheckBox->isChecked();
|
||||
|
||||
for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) {
|
||||
if (it.value()->isChecked()) {
|
||||
options.gameTypeIds.append(it.key());
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
QString CreateGameFormWidget::selectedGameTypesSettingString() const
|
||||
{
|
||||
QString gameTypes;
|
||||
for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) {
|
||||
if (it.value()->isChecked()) {
|
||||
gameTypes += it.value()->text() + ", ";
|
||||
}
|
||||
}
|
||||
return gameTypes;
|
||||
}
|
||||
|
||||
void CreateGameFormWidget::resetToDefaults()
|
||||
{
|
||||
GameCreationOptions defaults;
|
||||
defaults.onlyRegistered = registeredUser;
|
||||
setOptions(defaults);
|
||||
|
||||
// An exclusive button group refuses to uncheck its selected button, which setOptions
|
||||
// cannot do, so clear the exclusive flag while resetting the game type selection.
|
||||
for (auto *gameTypeCheckBox : gameTypeCheckBoxes) {
|
||||
gameTypeCheckBox->setAutoExclusive(false);
|
||||
gameTypeCheckBox->setChecked(false);
|
||||
gameTypeCheckBox->setAutoExclusive(true);
|
||||
}
|
||||
|
||||
descriptionEdit->setFocus();
|
||||
}
|
||||
|
||||
void CreateGameFormWidget::setReadOnly(bool readOnly)
|
||||
{
|
||||
const bool editable = !readOnly;
|
||||
rememberGameSettings->setEnabled(editable);
|
||||
descriptionEdit->setEnabled(editable);
|
||||
maxPlayersEdit->setEnabled(editable);
|
||||
passwordEdit->setEnabled(editable);
|
||||
onlyBuddiesCheckBox->setEnabled(editable && registeredUser);
|
||||
onlyRegisteredCheckBox->setEnabled(editable && registeredUser);
|
||||
spectatorsAllowedCheckBox->setEnabled(editable);
|
||||
spectatorsNeedPasswordCheckBox->setEnabled(editable);
|
||||
spectatorsCanTalkCheckBox->setEnabled(editable);
|
||||
spectatorsSeeEverythingCheckBox->setEnabled(editable);
|
||||
createGameAsSpectatorCheckBox->setEnabled(editable);
|
||||
startingLifeTotalEdit->setEnabled(editable);
|
||||
shareDecklistsOnLoadCheckBox->setEnabled(editable);
|
||||
shufflePlayersCheckBox->setEnabled(editable);
|
||||
for (auto *gameTypeCheckBox : gameTypeCheckBoxes) {
|
||||
gameTypeCheckBox->setEnabled(editable);
|
||||
}
|
||||
}
|
||||
|
||||
void CreateGameFormWidget::focusDescription()
|
||||
{
|
||||
descriptionEdit->setFocus();
|
||||
}
|
||||
|
||||
bool CreateGameFormWidget::isRememberSettingsChecked() const
|
||||
{
|
||||
return rememberGameSettings->isChecked();
|
||||
}
|
||||
|
||||
void CreateGameFormWidget::setRememberSettingsChecked(bool checked)
|
||||
{
|
||||
rememberGameSettings->setChecked(checked);
|
||||
}
|
||||
|
||||
void CreateGameFormWidget::spectatorsAllowedChanged(QT_STATE_CHANGED_T state)
|
||||
{
|
||||
spectatorsNeedPasswordCheckBox->setEnabled(state);
|
||||
spectatorsCanTalkCheckBox->setEnabled(state);
|
||||
spectatorsSeeEverythingCheckBox->setEnabled(state);
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* @file create_game_form_widget.h
|
||||
* @ingroup RoomWidgets
|
||||
*/
|
||||
|
||||
#ifndef CREATE_GAME_FORM_WIDGET_H
|
||||
#define CREATE_GAME_FORM_WIDGET_H
|
||||
|
||||
#include "game_creation_options.h"
|
||||
|
||||
#include <QMap>
|
||||
#include <QWidget>
|
||||
#include <libcockatrice/utility/macros.h>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
class QCheckBox;
|
||||
class QLineEdit;
|
||||
class QRadioButton;
|
||||
class QSpinBox;
|
||||
|
||||
/**
|
||||
* @brief The editable create-game form, independent of settings, the network, and the room.
|
||||
*
|
||||
* The dialog loads @ref GameCreationOptions into it and reads them back; a future host
|
||||
* activity can do the same without duplicating the widget layout.
|
||||
*/
|
||||
class CreateGameFormWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
CreateGameFormWidget(const QMap<int, QString> &gameTypes,
|
||||
bool registeredUser,
|
||||
bool judge,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
void setOptions(const GameCreationOptions &options);
|
||||
[[nodiscard]] GameCreationOptions options() const;
|
||||
[[nodiscard]] QString selectedGameTypesSettingString() const;
|
||||
void resetToDefaults();
|
||||
void setReadOnly(bool readOnly);
|
||||
void focusDescription();
|
||||
[[nodiscard]] bool isRememberSettingsChecked() const;
|
||||
void setRememberSettingsChecked(bool checked);
|
||||
|
||||
private slots:
|
||||
void spectatorsAllowedChanged(QT_STATE_CHANGED_T state);
|
||||
|
||||
private:
|
||||
bool registeredUser;
|
||||
QMap<int, QRadioButton *> gameTypeCheckBoxes;
|
||||
|
||||
QLineEdit *descriptionEdit;
|
||||
QLineEdit *passwordEdit;
|
||||
QSpinBox *maxPlayersEdit;
|
||||
QSpinBox *startingLifeTotalEdit;
|
||||
QCheckBox *onlyBuddiesCheckBox;
|
||||
QCheckBox *onlyRegisteredCheckBox;
|
||||
QCheckBox *spectatorsAllowedCheckBox;
|
||||
QCheckBox *spectatorsNeedPasswordCheckBox;
|
||||
QCheckBox *spectatorsCanTalkCheckBox;
|
||||
QCheckBox *spectatorsSeeEverythingCheckBox;
|
||||
QCheckBox *createGameAsJudgeCheckBox;
|
||||
QCheckBox *createGameAsSpectatorCheckBox;
|
||||
QCheckBox *shareDecklistsOnLoadCheckBox;
|
||||
QCheckBox *shufflePlayersCheckBox;
|
||||
QCheckBox *rememberGameSettings;
|
||||
};
|
||||
|
||||
#endif // CREATE_GAME_FORM_WIDGET_H
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
#include "game_creation_options.h"
|
||||
|
||||
#include <QSet>
|
||||
#include <libcockatrice/interfaces/interface_game_settings_provider.h>
|
||||
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
|
||||
|
||||
GameCreationOptions GameCreationOptions::fromSettings(const IGameSettingsProvider &settings,
|
||||
const QMap<int, QString> &gameTypes,
|
||||
bool registeredUser)
|
||||
{
|
||||
GameCreationOptions options;
|
||||
options.description = settings.getGameDescription();
|
||||
options.maxPlayers = settings.getMaxPlayers();
|
||||
if (registeredUser) {
|
||||
options.onlyBuddies = settings.getOnlyBuddies();
|
||||
options.onlyRegistered = settings.getOnlyRegistered();
|
||||
}
|
||||
options.spectatorsAllowed = settings.getSpectatorsAllowed();
|
||||
options.spectatorsNeedPassword = settings.getSpectatorsNeedPassword();
|
||||
options.spectatorsCanTalk = settings.getSpectatorsCanTalk();
|
||||
options.spectatorsSeeEverything = settings.getSpectatorsCanSeeEverything();
|
||||
options.joinAsSpectator = settings.getCreateGameAsSpectator();
|
||||
options.startingLifeTotal = settings.getDefaultStartingLifeTotal();
|
||||
options.shareDecklistsOnLoad = settings.getShareDecklistsOnLoad();
|
||||
options.shufflePlayers = settings.getShufflePlayers();
|
||||
|
||||
// The old dialog stored game type names as "Name, " in one string; keep the exact
|
||||
// substring test so an existing game.ini selects the same types after the split.
|
||||
const QString savedGameTypes = settings.getGameTypes();
|
||||
for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) {
|
||||
if (savedGameTypes.contains(it.value() + ", ")) {
|
||||
options.gameTypeIds.append(it.key());
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
GameCreationOptions GameCreationOptions::fromGameInfo(const ServerInfo_Game &gameInfo,
|
||||
const QMap<int, QString> &gameTypes)
|
||||
{
|
||||
GameCreationOptions options;
|
||||
options.description = QString::fromStdString(gameInfo.description());
|
||||
options.maxPlayers = gameInfo.max_players();
|
||||
options.onlyBuddies = gameInfo.only_buddies();
|
||||
options.onlyRegistered = gameInfo.only_registered();
|
||||
options.spectatorsAllowed = gameInfo.spectators_allowed();
|
||||
options.spectatorsNeedPassword = gameInfo.spectators_need_password();
|
||||
options.spectatorsCanTalk = gameInfo.spectators_can_chat();
|
||||
options.spectatorsSeeEverything = gameInfo.spectators_omniscient();
|
||||
options.shareDecklistsOnLoad = gameInfo.share_decklists_on_load();
|
||||
options.shufflePlayers = gameInfo.shuffle_players();
|
||||
|
||||
QSet<int> selectedTypes;
|
||||
for (int i = 0; i < gameInfo.game_types_size(); ++i) {
|
||||
selectedTypes.insert(gameInfo.game_types(i));
|
||||
}
|
||||
for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) {
|
||||
if (selectedTypes.contains(it.key())) {
|
||||
options.gameTypeIds.append(it.key());
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
Command_CreateGame GameCreationOptions::toCreateGameCommand() const
|
||||
{
|
||||
Command_CreateGame cmd;
|
||||
cmd.set_description(description.simplified().toStdString());
|
||||
cmd.set_password(password.toStdString());
|
||||
cmd.set_max_players(maxPlayers);
|
||||
cmd.set_only_buddies(onlyBuddies);
|
||||
cmd.set_only_registered(onlyRegistered);
|
||||
cmd.set_spectators_allowed(spectatorsAllowed);
|
||||
cmd.set_spectators_need_password(spectatorsNeedPassword);
|
||||
cmd.set_spectators_can_talk(spectatorsCanTalk);
|
||||
cmd.set_spectators_see_everything(spectatorsSeeEverything);
|
||||
cmd.set_join_as_judge(joinAsJudge);
|
||||
cmd.set_join_as_spectator(joinAsSpectator);
|
||||
cmd.set_starting_life_total(startingLifeTotal);
|
||||
cmd.set_share_decklists_on_load(shareDecklistsOnLoad);
|
||||
cmd.set_shuffle_players(shufflePlayers);
|
||||
for (int gameTypeId : gameTypeIds) {
|
||||
cmd.add_game_type_ids(gameTypeId);
|
||||
}
|
||||
return cmd;
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* @file game_creation_options.h
|
||||
* @ingroup RoomWidgets
|
||||
*/
|
||||
|
||||
#ifndef GAME_CREATION_OPTIONS_H
|
||||
#define GAME_CREATION_OPTIONS_H
|
||||
|
||||
#include "libcockatrice/protocol/pb/room_commands.pb.h"
|
||||
|
||||
#include <QList>
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
|
||||
class IGameSettingsProvider;
|
||||
class ServerInfo_Game;
|
||||
|
||||
/**
|
||||
* @brief The values needed to create a game, independent of the widgets that edit them.
|
||||
*
|
||||
* Pure value object: it holds no widget or settings dependencies, so every host entry
|
||||
* point can build a @ref Command_CreateGame through the same conversion.
|
||||
*/
|
||||
struct GameCreationOptions
|
||||
{
|
||||
QString description;
|
||||
QString password;
|
||||
int maxPlayers = 2;
|
||||
bool onlyBuddies = false;
|
||||
bool onlyRegistered = false;
|
||||
bool spectatorsAllowed = true;
|
||||
bool spectatorsNeedPassword = false;
|
||||
bool spectatorsCanTalk = false;
|
||||
bool spectatorsSeeEverything = false;
|
||||
bool joinAsJudge = false;
|
||||
bool joinAsSpectator = false;
|
||||
int startingLifeTotal = 20;
|
||||
bool shareDecklistsOnLoad = false;
|
||||
bool shufflePlayers = false;
|
||||
QList<int> gameTypeIds;
|
||||
|
||||
/**
|
||||
* @brief Reads the remembered create-game settings.
|
||||
*
|
||||
* Join restrictions are only meaningful for registered users, so they are left
|
||||
* unset for an unregistered host. Game type ids are resolved from the room's map
|
||||
* by matching the remembered names.
|
||||
*/
|
||||
[[nodiscard]] static GameCreationOptions
|
||||
fromSettings(const IGameSettingsProvider &settings, const QMap<int, QString> &gameTypes, bool registeredUser);
|
||||
|
||||
/**
|
||||
* @brief Reads the read-only fields shown in the game information view.
|
||||
*/
|
||||
[[nodiscard]] static GameCreationOptions fromGameInfo(const ServerInfo_Game &gameInfo,
|
||||
const QMap<int, QString> &gameTypes);
|
||||
|
||||
/**
|
||||
* @brief Builds the room command, simplifying the description's whitespace.
|
||||
*/
|
||||
[[nodiscard]] Command_CreateGame toCreateGameCommand() const;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
@ -21,12 +21,14 @@
|
|||
#include <QCheckBox>
|
||||
#include <QDebug>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHideEvent>
|
||||
#include <QList>
|
||||
#include <QMap>
|
||||
#include <QScrollArea>
|
||||
#include <QScrollBar>
|
||||
#include <QSharedPointer>
|
||||
#include <QSizePolicy>
|
||||
#include <QShowEvent>
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
|
@ -54,6 +56,9 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck
|
|||
setLayout(layout);
|
||||
|
||||
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);
|
||||
|
||||
|
|
@ -122,11 +127,36 @@ void PrintingSelector::retranslateUi()
|
|||
void PrintingSelector::printingsInDeckChanged()
|
||||
{
|
||||
if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) {
|
||||
// Delay the update to avoid race conditions
|
||||
QTimer::singleShot(100, this, &PrintingSelector::updateDisplay);
|
||||
displayDirty = true;
|
||||
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).
|
||||
*/
|
||||
|
|
@ -173,6 +203,8 @@ void PrintingSelector::updateCardAmounts()
|
|||
*/
|
||||
void PrintingSelector::updateDisplay()
|
||||
{
|
||||
deckChangeTimer->stop();
|
||||
displayDirty = false;
|
||||
widgetLoadingBufferTimer->stop();
|
||||
widgetLoadingBufferTimer->deleteLater();
|
||||
widgetLoadingBufferTimer = new QTimer(this);
|
||||
|
|
@ -199,20 +231,18 @@ void PrintingSelector::updateDisplay()
|
|||
*/
|
||||
void PrintingSelector::setCard(const CardInfoPtr &newCard)
|
||||
{
|
||||
if (newCard.isNull()) {
|
||||
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()) {
|
||||
// Same-name cards may be different database snapshots after a reload.
|
||||
if (selectedCard == newCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
widgetLoadingBufferTimer->stop();
|
||||
deckChangeTimer->stop();
|
||||
selectedCard = newCard;
|
||||
if (isVisible()) {
|
||||
updateDisplay();
|
||||
} else {
|
||||
displayDirty = true;
|
||||
}
|
||||
flowWidget->setMinimumSizeToMaxSizeHint();
|
||||
flowWidget->scrollArea->verticalScrollBar()->setValue(0);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ class QCheckBox;
|
|||
class QHBoxLayout;
|
||||
class QString;
|
||||
class QTimer;
|
||||
class QShowEvent;
|
||||
class QHideEvent;
|
||||
class QVBoxLayout;
|
||||
class SettingsButtonWidget;
|
||||
template <class Key, class T> class QMap;
|
||||
|
|
@ -47,6 +49,10 @@ public:
|
|||
void setCard(const CardInfoPtr &newCard);
|
||||
void getAllSetsForCurrentCard();
|
||||
|
||||
protected:
|
||||
void showEvent(QShowEvent *event) override;
|
||||
void hideEvent(QHideEvent *event) override;
|
||||
|
||||
public slots:
|
||||
void retranslateUi();
|
||||
void updateDisplay();
|
||||
|
|
@ -88,6 +94,8 @@ private:
|
|||
DeckStateManager *deckStateManager;
|
||||
CardInfoPtr selectedCard;
|
||||
QTimer *widgetLoadingBufferTimer;
|
||||
QTimer *deckChangeTimer;
|
||||
bool displayDirty = false;
|
||||
int currentIndex = 0;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -266,9 +266,13 @@ void GameSelector::actCreate()
|
|||
return;
|
||||
}
|
||||
|
||||
DlgCreateGame dlg(room, room->getGameTypes(), this);
|
||||
dlg.exec();
|
||||
updateTitle();
|
||||
// Heap-allocate the dialog: if this selector (or its room tab) is destroyed while the
|
||||
// dialog is open, e.g. on server disconnect, Qt deletes its children. A stack-allocated
|
||||
// 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)
|
||||
|
|
@ -362,8 +366,9 @@ void GameSelector::customContextMenu(const QPoint &point)
|
|||
const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt());
|
||||
const QMap<int, QString> &gameTypes = gameListModel->getGameTypes().value(gameInfo.room_id());
|
||||
|
||||
DlgCreateGame dlg(gameInfo, gameTypes, this);
|
||||
dlg.exec();
|
||||
auto *dlg = new DlgCreateGame(gameInfo, gameTypes, this);
|
||||
dlg->setAttribute(Qt::WA_DeleteOnClose);
|
||||
dlg->exec();
|
||||
});
|
||||
|
||||
QAction copyLink(tr("Cop&y game link"));
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
|
|||
aDemoteFromDeveloper = new QAction(QString(), this);
|
||||
aGetAdminNotes = new QAction(QString(), this);
|
||||
aInvestigateUser = new QAction(QString(), this);
|
||||
aInvestigateAlts = new QAction(QString(), this);
|
||||
|
||||
retranslateUi();
|
||||
}
|
||||
|
|
@ -111,6 +112,7 @@ void UserContextMenu::retranslateUi()
|
|||
aDemoteFromDeveloper->setText(tr("Demote user from de&veloper"));
|
||||
aGetAdminNotes->setText(tr("View admin notes"));
|
||||
aInvestigateUser->setText(tr("Investigate user"));
|
||||
aInvestigateAlts->setText(tr("Investigate &alts"));
|
||||
}
|
||||
|
||||
void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer)
|
||||
|
|
@ -454,6 +456,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
|||
menu->addSeparator();
|
||||
menu->addAction(aGetAdminNotes);
|
||||
menu->addAction(aInvestigateUser);
|
||||
menu->addAction(aInvestigateAlts);
|
||||
|
||||
menu->addSeparator();
|
||||
if (userLevel.testFlag(ServerInfo_User::IsModerator) &&
|
||||
|
|
@ -499,6 +502,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
|||
aBanHistory->setEnabled(anotherUser);
|
||||
aGetAdminNotes->setEnabled(anotherUser);
|
||||
aInvestigateUser->setEnabled(anotherUser);
|
||||
aInvestigateAlts->setEnabled(anotherUser);
|
||||
aPromoteToMod->setEnabled(anotherUser);
|
||||
aDemoteFromMod->setEnabled(anotherUser);
|
||||
aPromoteToJudge->setEnabled(anotherUser);
|
||||
|
|
@ -553,6 +557,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
|||
execAdminNotes(userName);
|
||||
} else if (actionClicked == aInvestigateUser) {
|
||||
execInvestigateUser(userName);
|
||||
} else if (actionClicked == aInvestigateAlts) {
|
||||
execInvestigateAlts(userName);
|
||||
} else if (actionClicked == aCopyToClipBoard) {
|
||||
QClipboard *clipboard = QGuiApplication::clipboard();
|
||||
clipboard->setText(deckHash);
|
||||
|
|
@ -745,6 +751,11 @@ void UserContextMenu::execInvestigateUser(const QString &userName)
|
|||
tabSupervisor->openTabModeration(userName);
|
||||
}
|
||||
|
||||
void UserContextMenu::execInvestigateAlts(const QString &userName)
|
||||
{
|
||||
tabSupervisor->openTabAlts(userName);
|
||||
}
|
||||
|
||||
void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod)
|
||||
{
|
||||
Command_AdjustMod cmd;
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ private:
|
|||
QAction *aGetAdminNotes;
|
||||
std::function<QList<GameInviteOption>()> gameInviteLinkProvider;
|
||||
QAction *aInvestigateUser;
|
||||
QAction *aInvestigateAlts;
|
||||
signals:
|
||||
void openMessageDialog(const QString &userName, bool focus);
|
||||
private slots:
|
||||
|
|
@ -127,6 +128,7 @@ public:
|
|||
void execWarnHistory(const QString &userName);
|
||||
void execAdminNotes(const QString &userName);
|
||||
void execInvestigateUser(const QString &userName);
|
||||
void execInvestigateAlts(const QString &userName);
|
||||
void execAdjustMod(const QString &userName, bool shouldBeMod);
|
||||
void execAdjustJudge(const QString &userName, bool shouldBeJudge);
|
||||
void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper);
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ AppearanceSettingsPage::AppearanceSettingsPage()
|
|||
});
|
||||
|
||||
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.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency());
|
||||
connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload<int>(&QSpinBox::valueChanged), &settings.appearance(),
|
||||
|
|
@ -675,7 +676,7 @@ void AppearanceSettingsPage::retranslateUi()
|
|||
}
|
||||
|
||||
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"));
|
||||
|
||||
tableGroupBox->setTitle(tr("Table grid layout"));
|
||||
|
|
|
|||
|
|
@ -88,18 +88,21 @@ MessagesSettingsPage::MessagesSettingsPage()
|
|||
|
||||
auto *chatGrid = new QGridLayout;
|
||||
chatGrid->addWidget(&chatMentionCheckBox, 0, 0);
|
||||
chatGrid->addWidget(&invertMentionForeground, 0, 1);
|
||||
chatGrid->addWidget(&mentionColorStringLabel, 0, 1, Qt::AlignRight);
|
||||
chatGrid->addWidget(mentionColor, 0, 2);
|
||||
chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0);
|
||||
chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0);
|
||||
chatGrid->addWidget(&hexLabel, 1, 2);
|
||||
chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0);
|
||||
chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0);
|
||||
chatGrid->addWidget(&messagePopups, 5, 0);
|
||||
chatGrid->addWidget(&mentionPopups, 6, 0);
|
||||
chatGrid->addWidget(&roomHistory, 7, 0);
|
||||
chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0);
|
||||
chatGrid->addWidget(&useGameTimeCheckBox, 9, 0);
|
||||
chatGrid->addWidget(&invertMentionForeground, 0, 3);
|
||||
chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0, 1, -1);
|
||||
chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0, 1, -1);
|
||||
chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0, 1, -1);
|
||||
chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0, 1, -1);
|
||||
chatGrid->addWidget(&messagePopups, 5, 0, 1, -1);
|
||||
chatGrid->addWidget(&mentionPopups, 6, 0, 1, -1);
|
||||
chatGrid->addWidget(&roomHistory, 7, 0, 1, -1);
|
||||
chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0, 1, -1);
|
||||
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->setLayout(chatGrid);
|
||||
|
||||
|
|
@ -109,11 +112,14 @@ MessagesSettingsPage::MessagesSettingsPage()
|
|||
connect(highlightColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateHighlightColor);
|
||||
|
||||
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(&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);
|
||||
highlightGroupBox = new QGroupBox;
|
||||
highlightGroupBox->setLayout(highlightNotice);
|
||||
|
|
@ -131,10 +137,8 @@ MessagesSettingsPage::MessagesSettingsPage()
|
|||
auto *channelGrid = new QGridLayout;
|
||||
channelGrid->addWidget(&channelUnreadColorLabel, 0, 0);
|
||||
channelGrid->addWidget(channelUnreadColor, 0, 1);
|
||||
channelGrid->addWidget(&hexChannelUnreadLabel, 0, 2);
|
||||
channelGrid->addWidget(&channelMentionColorLabel, 1, 0);
|
||||
channelGrid->addWidget(channelMentionColor, 1, 1);
|
||||
channelGrid->addWidget(&hexChannelMentionLabel, 1, 2);
|
||||
channelGroupBox = new QGroupBox;
|
||||
channelGroupBox->setLayout(channelGrid);
|
||||
|
||||
|
|
@ -331,6 +335,8 @@ void MessagesSettingsPage::retranslateUi()
|
|||
channelGroupBox->setTitle(tr("Room channel tabs"));
|
||||
channelUnreadColorLabel.setText(tr("Unread 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"));
|
||||
chatMentionCompleterCheckbox.setText(tr("Enable mention completer"));
|
||||
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"));
|
||||
invertMentionForeground.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"));
|
||||
mentionPopups.setText(tr("Enable desktop notification for mentions"));
|
||||
roomHistory.setText(tr("Enable room message history on join"));
|
||||
ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages"));
|
||||
useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs"));
|
||||
hexLabel.setText(tr("(Color is hexadecimal)"));
|
||||
hexHighlightLabel.setText(tr("(Color is hexadecimal)"));
|
||||
hexChannelUnreadLabel.setText(tr("(Color is hexadecimal)"));
|
||||
hexChannelMentionLabel.setText(tr("(Color is hexadecimal)"));
|
||||
customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only"));
|
||||
customAlertString->setPlaceholderText(tr("Word1 Word2 Word3"));
|
||||
aAdd->setText(tr("Add New Message"));
|
||||
|
|
|
|||
|
|
@ -58,13 +58,11 @@ private:
|
|||
QLineEdit *channelUnreadColor;
|
||||
QLineEdit *channelMentionColor;
|
||||
QLineEdit *customAlertString;
|
||||
QLabel hexLabel;
|
||||
QLabel hexHighlightLabel;
|
||||
QLabel channelUnreadColorLabel;
|
||||
QLabel channelMentionColorLabel;
|
||||
QLabel hexChannelUnreadLabel;
|
||||
QLabel hexChannelMentionLabel;
|
||||
QLabel customAlertStringLabel;
|
||||
QLabel mentionColorStringLabel;
|
||||
QLabel highlightColorStringLabel;
|
||||
QLabel explainMessagesLabel;
|
||||
|
||||
void storeSettings();
|
||||
|
|
|
|||
109
cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp
Normal file
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
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
|
||||
{
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
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
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()
|
||||
{
|
||||
if (aCardMenu && aCardMenu->menu() && aCardMenu->menu() != emptyCardMenu) {
|
||||
delete aCardMenu->menu();
|
||||
}
|
||||
for (auto &player : game->getPlayerManager()->getPlayers()) {
|
||||
player->clear();
|
||||
}
|
||||
|
|
@ -570,8 +573,10 @@ void TabGame::adminLockChanged(bool lock)
|
|||
|
||||
void TabGame::actGameInfo()
|
||||
{
|
||||
DlgCreateGame dlg(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this);
|
||||
dlg.exec();
|
||||
// Heap-allocated so that destroying this tab while the dialog is open cannot delete a stack object.
|
||||
auto *dlg = new DlgCreateGame(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this);
|
||||
dlg->setAttribute(Qt::WA_DeleteOnClose);
|
||||
dlg->exec();
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* 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)
|
||||
{
|
||||
if (!aCardMenu) {
|
||||
if (!aCardMenu || !emptyCardMenu) {
|
||||
if (menu) {
|
||||
menu->deleteLater();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1169,10 +1180,11 @@ void TabGame::setCardMenu(CardMenu *menu)
|
|||
gameView->viewport()->update();
|
||||
}
|
||||
}
|
||||
if (menu) {
|
||||
aCardMenu->setMenu(menu);
|
||||
} else {
|
||||
aCardMenu->setMenu(new QMenu);
|
||||
|
||||
QMenu *previousMenu = aCardMenu->menu();
|
||||
aCardMenu->setMenu(menu ? menu : emptyCardMenu);
|
||||
if (previousMenu && previousMenu != emptyCardMenu && previousMenu != menu) {
|
||||
previousMenu->deleteLater();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1256,7 +1268,8 @@ void TabGame::createMenuItems()
|
|||
|
||||
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.
|
||||
updateInviteButtonState();
|
||||
|
|
@ -1288,6 +1301,7 @@ void TabGame::createReplayMenuItems()
|
|||
gameMenu->addAction(aLeaveGame);
|
||||
|
||||
aCardMenu = nullptr;
|
||||
emptyCardMenu = nullptr;
|
||||
|
||||
addTabMenu(gameMenu);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ private:
|
|||
QAction *aInviteToGame = nullptr;
|
||||
QList<QAction *> phaseActions;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
#include "tab_moderation.h"
|
||||
|
||||
#include "../utility/epoch_format.h"
|
||||
#include "../utility/table_activation.h"
|
||||
#include "abstract_client.h"
|
||||
#include "libcockatrice/protocol/pb/admin_commands.pb.h"
|
||||
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h"
|
||||
#include "tab_supervisor.h"
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QDateTime>
|
||||
|
|
@ -21,6 +24,7 @@
|
|||
#include <QSharedPointer>
|
||||
#include <QSplitter>
|
||||
#include <QStringList>
|
||||
#include <QTabWidget>
|
||||
#include <QTableWidget>
|
||||
#include <QTableWidgetItem>
|
||||
#include <QTextEdit>
|
||||
|
|
@ -99,38 +103,67 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
|
|||
topBar->addWidget(removeBannerButton);
|
||||
|
||||
userInfoGroup = new QGroupBox;
|
||||
userInfoNameLabel = new QLabel;
|
||||
userInfoNameValue = new QLabel;
|
||||
userInfoRegisteredLabel = new QLabel;
|
||||
userInfoRegisteredValue = new QLabel;
|
||||
userInfoLastLoginLabel = new QLabel;
|
||||
userInfoLastLoginValue = new QLabel;
|
||||
userInfoStatusLabel = new QLabel;
|
||||
QFont displayFont = userInfoNameValue->font();
|
||||
displayFont.setPointSize(displayFont.pointSize() + 6);
|
||||
displayFont.setBold(true);
|
||||
userInfoNameValue->setFont(displayFont);
|
||||
userInfoStatusValue = new QLabel;
|
||||
userInfoCountsLabel = new QLabel;
|
||||
userInfoCountsValue = new QLabel;
|
||||
QFont statusFont = userInfoStatusValue->font();
|
||||
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;
|
||||
userInfoNotesEdit = new QTextEdit;
|
||||
userInfoNotesEdit->setReadOnly(true);
|
||||
|
||||
auto *infoGrid = new QGridLayout;
|
||||
infoGrid->addWidget(userInfoNameLabel, 0, 0);
|
||||
infoGrid->addWidget(userInfoNameValue, 0, 1);
|
||||
infoGrid->addWidget(userInfoRegisteredLabel, 0, 2);
|
||||
infoGrid->addWidget(userInfoRegisteredValue, 0, 3);
|
||||
infoGrid->addWidget(userInfoLastLoginLabel, 1, 0);
|
||||
infoGrid->addWidget(userInfoLastLoginValue, 1, 1);
|
||||
infoGrid->addWidget(userInfoStatusLabel, 1, 2);
|
||||
infoGrid->addWidget(userInfoStatusValue, 1, 3);
|
||||
infoGrid->addWidget(userInfoCountsLabel, 2, 0);
|
||||
infoGrid->addWidget(userInfoCountsValue, 2, 1, 1, 3);
|
||||
infoGrid->addWidget(userInfoNotesLabel, 3, 0, Qt::AlignTop);
|
||||
infoGrid->addWidget(userInfoNotesEdit, 3, 1, 1, 3);
|
||||
infoGrid->setColumnStretch(1, 1);
|
||||
infoGrid->setColumnStretch(3, 1);
|
||||
auto *identityRow = new QHBoxLayout;
|
||||
identityRow->addWidget(userInfoNameValue);
|
||||
identityRow->addStretch();
|
||||
identityRow->addWidget(userInfoStatusValue);
|
||||
|
||||
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) {
|
||||
table->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
|
|
@ -142,19 +175,27 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
|
|||
table->horizontalHeader()->setStretchLastSection(true);
|
||||
};
|
||||
|
||||
altsGroup = new QGroupBox;
|
||||
altsTable = new QTableWidget(0, COL_ALTS_COUNT);
|
||||
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);
|
||||
|
||||
sessionsGroup = new QGroupBox;
|
||||
sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT);
|
||||
configureTable(sessionsTable);
|
||||
auto *sessionsLayout = new QVBoxLayout(sessionsGroup);
|
||||
auto *sessionsPage = new QWidget;
|
||||
auto *sessionsLayout = new QVBoxLayout(sessionsPage);
|
||||
sessionsLayout->addWidget(sessionsTable);
|
||||
|
||||
staffGroup = new QGroupBox;
|
||||
staffTable = new QTableWidget(0, COL_STAFF_COUNT);
|
||||
configureTable(staffTable);
|
||||
refreshStaffButton = new QPushButton;
|
||||
|
|
@ -162,19 +203,21 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
|
|||
auto *staffHeader = new QHBoxLayout;
|
||||
staffHeader->addStretch();
|
||||
staffHeader->addWidget(refreshStaffButton);
|
||||
auto *staffLayout = new QVBoxLayout(staffGroup);
|
||||
auto *staffPage = new QWidget;
|
||||
auto *staffLayout = new QVBoxLayout(staffPage);
|
||||
staffLayout->addWidget(staffTable);
|
||||
staffLayout->addLayout(staffHeader);
|
||||
|
||||
sectionTabs = new QTabWidget;
|
||||
sectionTabs->addTab(altsPage, QString());
|
||||
sectionTabs->addTab(sessionsPage, QString());
|
||||
sectionTabs->addTab(staffPage, QString());
|
||||
|
||||
auto *splitter = new QSplitter(Qt::Vertical);
|
||||
splitter->addWidget(userInfoGroup);
|
||||
splitter->addWidget(altsGroup);
|
||||
splitter->addWidget(sessionsGroup);
|
||||
splitter->addWidget(staffGroup);
|
||||
splitter->addWidget(sectionTabs);
|
||||
splitter->setStretchFactor(0, 1);
|
||||
splitter->setStretchFactor(1, 2);
|
||||
splitter->setStretchFactor(2, 2);
|
||||
splitter->setStretchFactor(3, 1);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout(centralWidget);
|
||||
mainLayout->addLayout(topBar);
|
||||
|
|
@ -196,45 +239,56 @@ void TabModeration::retranslateUi()
|
|||
refreshStaffButton->setText(tr("Refresh"));
|
||||
|
||||
userInfoGroup->setTitle(tr("User Info"));
|
||||
userInfoNameLabel->setText(tr("Name:"));
|
||||
userInfoRegisteredLabel->setText(tr("Registered:"));
|
||||
userInfoLastLoginLabel->setText(tr("Last login:"));
|
||||
userInfoStatusLabel->setText(tr("Status:"));
|
||||
userInfoCountsLabel->setText(tr("Counts:"));
|
||||
userInfoNotesLabel->setText(tr("Admin notes:"));
|
||||
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"));
|
||||
sessionsGroup->setTitle(tr("Sessions"));
|
||||
staffGroup->setTitle(tr("Staff Last Logins"));
|
||||
sectionTabs->setTabText(0, tr("Alts"));
|
||||
sectionTabs->setTabText(1, tr("Sessions"));
|
||||
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"),
|
||||
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")});
|
||||
staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")});
|
||||
}
|
||||
|
||||
QString TabModeration::formatEpoch(quint64 ts) const
|
||||
{
|
||||
if (ts == 0) {
|
||||
return tr("Unknown");
|
||||
}
|
||||
return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm");
|
||||
return formatEpochTimestamp(ts, tr("Unknown"));
|
||||
}
|
||||
|
||||
void TabModeration::clearUserData()
|
||||
{
|
||||
currentUser.clear();
|
||||
cachedUserInfoValid = false;
|
||||
cachedAltsCount = -1;
|
||||
userInfoNameValue->clear();
|
||||
userInfoRegisteredValue->clear();
|
||||
userInfoLastLoginValue->clear();
|
||||
userInfoStatusValue->clear();
|
||||
userInfoCountsValue->clear();
|
||||
sublineLabel->clear();
|
||||
cardReportsValue->setText(QStringLiteral("–"));
|
||||
cardWarnsValue->setText(QStringLiteral("–"));
|
||||
cardBansValue->setText(QStringLiteral("–"));
|
||||
altsCountValue->setText(QStringLiteral("–"));
|
||||
userInfoNotesEdit->clear();
|
||||
altsTable->setRowCount(0);
|
||||
sessionsTable->setRowCount(0);
|
||||
resetPasswordButton->setEnabled(false);
|
||||
removeAvatarButton->setEnabled(false);
|
||||
removeBannerButton->setEnabled(false);
|
||||
openAltsButton->setEnabled(false);
|
||||
}
|
||||
|
||||
void TabModeration::investigate(const QString &userName)
|
||||
|
|
@ -253,9 +307,13 @@ void TabModeration::investigateUser()
|
|||
return;
|
||||
}
|
||||
currentUser = userName;
|
||||
cachedUserInfoValid = false;
|
||||
cachedAltsCount = -1;
|
||||
resetPasswordButton->setEnabled(true);
|
||||
removeAvatarButton->setEnabled(true);
|
||||
removeBannerButton->setEnabled(true);
|
||||
openAltsButton->setEnabled(true);
|
||||
altsCountValue->setText(QStringLiteral("…"));
|
||||
altsTable->setRowCount(0);
|
||||
sessionsTable->setRowCount(0);
|
||||
requestUserInfo(userName);
|
||||
|
|
@ -263,13 +321,22 @@ void TabModeration::investigateUser()
|
|||
requestAlts(userName);
|
||||
}
|
||||
|
||||
void TabModeration::openAltsGraph()
|
||||
{
|
||||
if (currentUser.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
getTabSupervisor()->openTabAlts(currentUser);
|
||||
}
|
||||
|
||||
void TabModeration::requestUserInfo(const QString &userName)
|
||||
{
|
||||
userInfoNameValue->setText(userName);
|
||||
userInfoRegisteredValue->setText(tr("Loading..."));
|
||||
userInfoLastLoginValue->setText(tr("Loading..."));
|
||||
sublineLabel->setText(tr("Loading..."));
|
||||
userInfoStatusValue->clear();
|
||||
userInfoCountsValue->clear();
|
||||
cardReportsValue->setText(QStringLiteral("…"));
|
||||
cardWarnsValue->setText(QStringLiteral("…"));
|
||||
cardBansValue->setText(QStringLiteral("…"));
|
||||
userInfoNotesEdit->clear();
|
||||
|
||||
Command_ReportUserInfo cmd;
|
||||
|
|
@ -282,9 +349,12 @@ void TabModeration::requestUserInfo(const QString &userName)
|
|||
void TabModeration::userInfoResponse(const Response &response)
|
||||
{
|
||||
if (response.response_code() != Response::RespOk) {
|
||||
userInfoRegisteredValue->clear();
|
||||
userInfoLastLoginValue->clear();
|
||||
cachedUserInfoValid = false;
|
||||
sublineLabel->clear();
|
||||
userInfoStatusValue->setText(tr("Error loading user info."));
|
||||
cardReportsValue->setText(QStringLiteral("–"));
|
||||
cardWarnsValue->setText(QStringLiteral("–"));
|
||||
cardBansValue->setText(QStringLiteral("–"));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -293,20 +363,28 @@ void TabModeration::userInfoResponse(const Response &response)
|
|||
return;
|
||||
}
|
||||
|
||||
userInfoRegisteredValue->setText(formatEpoch(resp.registration_time()));
|
||||
userInfoLastLoginValue->setText(formatEpoch(resp.last_login()));
|
||||
cachedUserInfo.CopyFrom(resp);
|
||||
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;
|
||||
statusParts << (resp.is_active() ? tr("active") : tr("inactive"));
|
||||
if (resp.has_is_admin() && resp.is_admin()) {
|
||||
statusParts << tr("admin");
|
||||
}
|
||||
userInfoStatusValue->setText(statusParts.join(", "));
|
||||
userInfoStatusValue->setText(statusParts.join(QStringLiteral(", ")));
|
||||
|
||||
userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3")
|
||||
.arg(resp.total_reports())
|
||||
.arg(resp.total_bans())
|
||||
.arg(resp.total_warns()));
|
||||
cardReportsValue->setText(QString::number(resp.total_reports()));
|
||||
cardWarnsValue->setText(QString::number(resp.total_warns()));
|
||||
cardBansValue->setText(QString::number(resp.total_bans()));
|
||||
|
||||
if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
|
||||
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)
|
||||
{
|
||||
Command_GetUserSessions cmd;
|
||||
cmd.set_user_name(userName.toStdString());
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -352,7 +439,11 @@ void TabModeration::requestAlts(const QString &userName)
|
|||
Command_GetUserAlts cmd;
|
||||
cmd.set_user_name(userName.toStdString());
|
||||
PendingCommand *pend = client->prepareModeratorCommand(cmd);
|
||||
connect(pend, &PendingCommand::finished, this, &TabModeration::altsResponse);
|
||||
connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) {
|
||||
if (userName == currentUser) {
|
||||
altsResponse(response);
|
||||
}
|
||||
});
|
||||
client->sendCommand(pend);
|
||||
}
|
||||
|
||||
|
|
@ -360,10 +451,21 @@ void TabModeration::altsResponse(const Response &response)
|
|||
{
|
||||
altsTable->setRowCount(0);
|
||||
if (response.response_code() != Response::RespOk) {
|
||||
cachedAltsCount = -1;
|
||||
altsCountValue->setText(QStringLiteral("–"));
|
||||
return;
|
||||
}
|
||||
|
||||
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());
|
||||
for (int i = 0; i < resp.alts_size(); ++i) {
|
||||
const ServerInfo_UserAlt &alt = resp.alts(i);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
#include <QString>
|
||||
#include <QtGlobal>
|
||||
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
class AbstractClient;
|
||||
|
|
@ -13,6 +14,7 @@ class QLabel;
|
|||
class QLineEdit;
|
||||
class QPushButton;
|
||||
class QTableWidget;
|
||||
class QTabWidget;
|
||||
class QTextEdit;
|
||||
class Response;
|
||||
class TabSupervisor;
|
||||
|
|
@ -36,6 +38,7 @@ public:
|
|||
|
||||
private slots:
|
||||
void investigateUser();
|
||||
void openAltsGraph();
|
||||
void userInfoResponse(const Response &response);
|
||||
void sessionsResponse(const Response &response);
|
||||
void altsResponse(const Response &response);
|
||||
|
|
@ -53,10 +56,15 @@ private:
|
|||
void requestAlts(const QString &userName);
|
||||
void requestModeratorLogins();
|
||||
void clearUserData();
|
||||
void applyUserInfo();
|
||||
void applyAltsCount();
|
||||
[[nodiscard]] QString formatEpoch(quint64 ts) const;
|
||||
|
||||
AbstractClient *client;
|
||||
QString currentUser;
|
||||
Response_ReportUserInfo cachedUserInfo;
|
||||
bool cachedUserInfoValid = false;
|
||||
int cachedAltsCount = -1;
|
||||
|
||||
QLineEdit *searchEdit;
|
||||
QPushButton *investigateButton;
|
||||
|
|
@ -65,25 +73,25 @@ private:
|
|||
QPushButton *removeBannerButton;
|
||||
|
||||
QGroupBox *userInfoGroup;
|
||||
QLabel *userInfoNameLabel;
|
||||
QLabel *userInfoNameValue;
|
||||
QLabel *userInfoRegisteredLabel;
|
||||
QLabel *userInfoRegisteredValue;
|
||||
QLabel *userInfoLastLoginLabel;
|
||||
QLabel *userInfoLastLoginValue;
|
||||
QLabel *userInfoStatusLabel;
|
||||
QLabel *userInfoStatusValue;
|
||||
QLabel *userInfoCountsLabel;
|
||||
QLabel *userInfoCountsValue;
|
||||
QLabel *sublineLabel;
|
||||
QGroupBox *cardReports;
|
||||
QGroupBox *cardWarns;
|
||||
QGroupBox *cardBans;
|
||||
QGroupBox *altsCard;
|
||||
QLabel *cardReportsValue;
|
||||
QLabel *cardWarnsValue;
|
||||
QLabel *cardBansValue;
|
||||
QLabel *altsCountValue;
|
||||
QLabel *userInfoNotesLabel;
|
||||
QTextEdit *userInfoNotesEdit;
|
||||
|
||||
QGroupBox *altsGroup;
|
||||
QTabWidget *sectionTabs;
|
||||
QTableWidget *altsTable;
|
||||
QGroupBox *sessionsGroup;
|
||||
QTableWidget *sessionsTable;
|
||||
QGroupBox *staffGroup;
|
||||
QTableWidget *staffTable;
|
||||
QPushButton *openAltsButton;
|
||||
QPushButton *refreshStaffButton;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include "../utility/report_utils.h"
|
||||
#include "abstract_client.h"
|
||||
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||
#include "report_status_delegate.h"
|
||||
#include "tab_supervisor.h"
|
||||
|
||||
#include <QAbstractItemView>
|
||||
|
|
@ -24,7 +25,7 @@
|
|||
#include <QPushButton>
|
||||
#include <QSharedPointer>
|
||||
#include <QSignalBlocker>
|
||||
#include <QSplitter>
|
||||
#include <QTabWidget>
|
||||
#include <QTableWidget>
|
||||
#include <QTableWidgetItem>
|
||||
#include <QTextCursor>
|
||||
|
|
@ -126,26 +127,43 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
|||
connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged);
|
||||
table->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu);
|
||||
table->setItemDelegateForColumn(COL_STATUS, new ReportStatusDelegate(table));
|
||||
userContextMenu = new UserContextMenu(tabSupervisor, this);
|
||||
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->setReadOnly(true);
|
||||
auto *descLayout = new QVBoxLayout(descGroup);
|
||||
auto *descLayout = new QVBoxLayout(descPage);
|
||||
descLayout->setContentsMargins(4, 4, 4, 4);
|
||||
descLayout->addWidget(descriptionEdit);
|
||||
|
||||
chatGroup = new QGroupBox;
|
||||
auto *chatPage = new QWidget;
|
||||
chatLogEdit = new QTextEdit;
|
||||
chatLogEdit->setReadOnly(true);
|
||||
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->addWidget(chatLogEdit);
|
||||
|
||||
commentsGroup = new QGroupBox;
|
||||
auto *commentsPage = new QWidget;
|
||||
commentsEdit = new QTextEdit;
|
||||
commentsEdit->setReadOnly(true);
|
||||
auto *commentInputRow = new QHBoxLayout;
|
||||
|
|
@ -156,12 +174,12 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
|||
connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment);
|
||||
commentInputRow->addWidget(commentInput);
|
||||
commentInputRow->addWidget(commentButton);
|
||||
auto *commentsLayout = new QVBoxLayout(commentsGroup);
|
||||
auto *commentsLayout = new QVBoxLayout(commentsPage);
|
||||
commentsLayout->setContentsMargins(4, 4, 4, 4);
|
||||
commentsLayout->addWidget(commentsEdit);
|
||||
commentsLayout->addLayout(commentInputRow);
|
||||
|
||||
userContextGroup = new QGroupBox;
|
||||
auto *userPage = new QWidget;
|
||||
userContextName = new QLabel;
|
||||
userContextAccountAge = new QLabel;
|
||||
userContextReports = new QLabel;
|
||||
|
|
@ -180,7 +198,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
|||
userContextWarnsLabel = new QLabel;
|
||||
userContextNotesLabel = new QLabel;
|
||||
userContextRecentReportsLabel = new QLabel;
|
||||
auto *ucLayout = new QGridLayout(userContextGroup);
|
||||
auto *ucLayout = new QGridLayout(userPage);
|
||||
ucLayout->setContentsMargins(8, 8, 8, 8);
|
||||
ucLayout->addWidget(userContextUserLabel, 0, 0);
|
||||
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(userContextRecentReportsLabel, 4, 0, 1, 4);
|
||||
ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4);
|
||||
userContextGroup->setVisible(false);
|
||||
|
||||
statsGroup = new QGroupBox;
|
||||
statsGroup->setCheckable(true);
|
||||
|
|
@ -223,13 +240,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
|||
}
|
||||
});
|
||||
|
||||
detailSplitter = new QSplitter(Qt::Vertical);
|
||||
detailSplitter->addWidget(descGroup);
|
||||
detailSplitter->addWidget(chatGroup);
|
||||
detailSplitter->addWidget(commentsGroup);
|
||||
detailSplitter->setStretchFactor(0, 1);
|
||||
detailSplitter->setStretchFactor(1, 1);
|
||||
detailSplitter->setStretchFactor(2, 2);
|
||||
detailTabs = new QTabWidget;
|
||||
detailTabs->addTab(descPage, QString());
|
||||
detailTabs->addTab(chatPage, QString());
|
||||
detailTabs->addTab(commentsPage, QString());
|
||||
detailTabs->addTab(userPage, QString());
|
||||
|
||||
assignButton = new QPushButton;
|
||||
resolveButton = new QPushButton;
|
||||
|
|
@ -261,12 +276,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
|||
layout->addLayout(topBar);
|
||||
layout->addWidget(statsLabel);
|
||||
layout->addWidget(table, 2);
|
||||
layout->addWidget(detailSplitter, 1);
|
||||
layout->addWidget(userContextGroup);
|
||||
layout->addLayout(reportIdentityRow);
|
||||
layout->addWidget(reportHeaderSubline);
|
||||
layout->addWidget(detailTabs, 2);
|
||||
layout->addWidget(statsGroup);
|
||||
layout->addLayout(actionBar);
|
||||
|
||||
retranslateUi();
|
||||
clearReportHeader();
|
||||
|
||||
setActionsEnabled(false);
|
||||
refreshList();
|
||||
|
|
@ -293,18 +310,18 @@ void TabReport::retranslateUi()
|
|||
table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"),
|
||||
tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")});
|
||||
|
||||
descGroup->setTitle(tr("Description"));
|
||||
chatGroup->setTitle(tr("Chat Log Context"));
|
||||
chatGroup->setToolTip(
|
||||
tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited."));
|
||||
commentsGroup->setTitle(tr("Comments / Thread"));
|
||||
detailTabs->setTabText(0, tr("Description"));
|
||||
detailTabs->setTabText(1, tr("Chat Log Context"));
|
||||
detailTabs->setTabToolTip(1, tr("Chat log attached by the reporter. It is captured from their client and may be "
|
||||
"incomplete or edited."));
|
||||
detailTabs->setTabText(2, tr("Comments / Thread"));
|
||||
detailTabs->setTabText(3, tr("Reported User"));
|
||||
descriptionEdit->setPlaceholderText(tr("No description."));
|
||||
chatLogEdit->setPlaceholderText(tr("No chat log attached."));
|
||||
commentsEdit->setPlaceholderText(tr("No comments yet."));
|
||||
commentInput->setPlaceholderText(tr("Type a reply..."));
|
||||
commentButton->setText(tr("Send"));
|
||||
|
||||
userContextGroup->setTitle(tr("Reported User Context"));
|
||||
userContextUserLabel->setText(tr("User:"));
|
||||
userContextAgeLabel->setText(tr("Account Age:"));
|
||||
userContextReportsLabel->setText(tr("Reports:"));
|
||||
|
|
@ -312,10 +329,18 @@ void TabReport::retranslateUi()
|
|||
userContextWarnsLabel->setText(tr("Warns:"));
|
||||
userContextNotesLabel->setText(tr("Admin Notes:"));
|
||||
userContextRecentReportsLabel->setText(tr("Recent Reports Against User:"));
|
||||
userContextAccountAge->setText(QString());
|
||||
userContextReports->setText(QString());
|
||||
userContextBans->setText(QString());
|
||||
userContextWarns->setText(QString());
|
||||
|
||||
if (headerReportId >= 0) {
|
||||
for (const ServerInfo_Report &r : filteredReports) {
|
||||
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"));
|
||||
|
||||
|
|
@ -408,7 +433,8 @@ void TabReport::reportListResponse(const Response &response)
|
|||
chatLogEdit->clear();
|
||||
commentsEdit->clear();
|
||||
setActionsEnabled(false);
|
||||
userContextGroup->setVisible(false);
|
||||
clearReportHeader();
|
||||
clearUserContext();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -445,11 +471,14 @@ void TabReport::applyFilters()
|
|||
table->setSortingEnabled(false);
|
||||
table->setRowCount(filteredReports.size());
|
||||
|
||||
QFont reportedFont = table->font();
|
||||
reportedFont.setBold(true);
|
||||
|
||||
for (int row = 0; row < filteredReports.size(); ++row) {
|
||||
const ServerInfo_Report &r = filteredReports[row];
|
||||
|
||||
report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID,
|
||||
COL_STATUS, COL_ASSIGNED);
|
||||
COL_STATUS, COL_ASSIGNED, false);
|
||||
|
||||
table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name())));
|
||||
|
||||
|
|
@ -458,12 +487,30 @@ void TabReport::applyFilters()
|
|||
|
||||
table->setItem(row, COL_ROOM,
|
||||
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->resizeColumnsToContents();
|
||||
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()));
|
||||
}
|
||||
|
||||
|
|
@ -490,6 +537,31 @@ void TabReport::updateStats()
|
|||
.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()
|
||||
{
|
||||
const int row = table->currentRow();
|
||||
|
|
@ -500,7 +572,8 @@ void TabReport::onSelectionChanged()
|
|||
commentInput->clear();
|
||||
commentButton->setEnabled(false);
|
||||
setActionsEnabled(false);
|
||||
userContextGroup->setVisible(false);
|
||||
clearReportHeader();
|
||||
clearUserContext();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -509,6 +582,18 @@ void TabReport::onSelectionChanged()
|
|||
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)
|
||||
{
|
||||
QTableWidgetItem *clickedItem = table->itemAt(pos);
|
||||
|
|
@ -546,12 +631,17 @@ void TabReport::loadReportDetails(int reportId)
|
|||
for (const ServerInfo_Report &r : filteredReports) {
|
||||
if (r.report_id() == reportId) {
|
||||
descriptionEdit->setPlainText(QString::fromStdString(r.description()));
|
||||
updateReportHeader(r);
|
||||
if (headerReportId != reportId) {
|
||||
headerReportId = reportId;
|
||||
detailTabs->setCurrentIndex(0);
|
||||
}
|
||||
|
||||
QString reportedUser = QString::fromStdString(r.reported_user_name());
|
||||
if (!reportedUser.isEmpty()) {
|
||||
requestUserInfo(reportedUser);
|
||||
} else {
|
||||
userContextGroup->setVisible(false);
|
||||
clearUserContext();
|
||||
}
|
||||
|
||||
QString status = QString::fromStdString(r.status());
|
||||
|
|
@ -871,12 +961,11 @@ void TabReport::addCommentResponse(const Response &response)
|
|||
void TabReport::requestUserInfo(const QString &userName)
|
||||
{
|
||||
if (userName.isEmpty()) {
|
||||
userContextGroup->setVisible(false);
|
||||
clearUserContext();
|
||||
return;
|
||||
}
|
||||
|
||||
lastRequestedUser = userName;
|
||||
userContextGroup->setVisible(true);
|
||||
userContextName->setText(userName);
|
||||
userContextAccountAge->setText(tr("Loading..."));
|
||||
userContextReports->clear();
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ class QGroupBox;
|
|||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QPushButton;
|
||||
class QSplitter;
|
||||
class QTableWidget;
|
||||
class QTabWidget;
|
||||
class QTextEdit;
|
||||
class QTimer;
|
||||
class GameReplay;
|
||||
|
|
@ -72,6 +72,9 @@ private:
|
|||
void applyFilters();
|
||||
void updateStats();
|
||||
void loadReportDetails(int reportId);
|
||||
void updateReportHeader(const ServerInfo_Report &report);
|
||||
void clearReportHeader();
|
||||
void clearUserContext();
|
||||
|
||||
AbstractClient *client;
|
||||
|
||||
|
|
@ -81,10 +84,10 @@ private:
|
|||
QPushButton *refreshButton;
|
||||
QLabel *statsLabel;
|
||||
QTableWidget *table;
|
||||
QSplitter *detailSplitter;
|
||||
QGroupBox *descGroup;
|
||||
QGroupBox *chatGroup;
|
||||
QGroupBox *commentsGroup;
|
||||
QLabel *reportHeaderName;
|
||||
QLabel *reportHeaderStatus;
|
||||
QLabel *reportHeaderSubline;
|
||||
QTabWidget *detailTabs;
|
||||
QTextEdit *descriptionEdit;
|
||||
QTextEdit *chatLogEdit;
|
||||
QTextEdit *commentsEdit;
|
||||
|
|
@ -100,7 +103,6 @@ private:
|
|||
QTimer *refreshTimer;
|
||||
UserContextMenu *userContextMenu;
|
||||
|
||||
QGroupBox *userContextGroup;
|
||||
QLabel *userContextName;
|
||||
QLabel *userContextAccountAge;
|
||||
QLabel *userContextReports;
|
||||
|
|
@ -125,6 +127,7 @@ private:
|
|||
|
||||
QList<ServerInfo_Report> allReports;
|
||||
QList<ServerInfo_Report> filteredReports;
|
||||
int headerReportId = -1;
|
||||
int detailsRequestedReportId = -1;
|
||||
int detailsRequestSeq = 0;
|
||||
int selectedReportIdBeforeRefresh = -1;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
#include "tab.h"
|
||||
#include "tab_account.h"
|
||||
#include "tab_admin.h"
|
||||
#include "tab_alts.h"
|
||||
#include "tab_card_art_rules.h"
|
||||
#include "tab_deck_editor.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),
|
||||
tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(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");
|
||||
|
||||
|
|
@ -575,6 +576,7 @@ void TabSupervisor::startLocal(const QList<AbstractClient *> &_clients)
|
|||
tabLog = nullptr;
|
||||
tabReport = nullptr;
|
||||
tabModeration = nullptr;
|
||||
tabAlts = nullptr;
|
||||
tabDeveloper = nullptr;
|
||||
isLocalGame = true;
|
||||
userInfo = new ServerInfo_User;
|
||||
|
|
@ -623,6 +625,9 @@ void TabSupervisor::stop()
|
|||
if (tabModeration) {
|
||||
tabModeration->close();
|
||||
}
|
||||
if (tabAlts) {
|
||||
tabAlts->close();
|
||||
}
|
||||
if (tabCardArtRules) {
|
||||
tabCardArtRules->close();
|
||||
}
|
||||
|
|
@ -931,6 +936,21 @@ void TabSupervisor::openTabModeration(const QString &userName)
|
|||
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)
|
||||
{
|
||||
if (checked && !tabDeveloper) {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ class TabAdmin;
|
|||
class TabMessage;
|
||||
class TabReport;
|
||||
class TabModeration;
|
||||
class TabAlts;
|
||||
class TabAccount;
|
||||
class TabDeckEditor;
|
||||
class TabDeveloper;
|
||||
|
|
@ -116,6 +117,7 @@ private:
|
|||
TabLog *tabLog;
|
||||
TabReport *tabReport;
|
||||
TabModeration *tabModeration;
|
||||
TabAlts *tabAlts;
|
||||
TabDeveloper *tabDeveloper;
|
||||
QMap<int, TabRoom *> roomTabs;
|
||||
QMap<int, TabGame *> gameTabs;
|
||||
|
|
@ -209,6 +211,7 @@ public slots:
|
|||
/** Joins or spectates a game listed on a user's profile hover card. */
|
||||
void joinGameFromUserCard(int gameId, int roomId, bool asSpectator);
|
||||
void openTabModeration(const QString &userName = {});
|
||||
void openTabAlts(const QString &userName = {});
|
||||
void switchToFirstAvailableNetworkTab();
|
||||
void maximizeMainWindow();
|
||||
void actTabVisualDeckStorage(bool checked);
|
||||
|
|
|
|||
27
cockatrice/src/interface/widgets/utility/epoch_format.h
Normal file
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
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
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");
|
||||
}
|
||||
|
||||
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,
|
||||
int row,
|
||||
const ServerInfo_Report &report,
|
||||
|
|
@ -89,7 +106,8 @@ void fillReportTableRow(QTableWidget *table,
|
|||
int categoryColumn,
|
||||
int gameIdColumn,
|
||||
int statusColumn,
|
||||
int assignedColumn)
|
||||
int assignedColumn,
|
||||
bool colorizeStatus)
|
||||
{
|
||||
auto *idItem = new QTableWidgetItem(QString::number(report.report_id()));
|
||||
idItem->setData(Qt::UserRole, report.report_id());
|
||||
|
|
@ -102,8 +120,12 @@ void fillReportTableRow(QTableWidget *table,
|
|||
table->setItem(row, gameIdColumn,
|
||||
new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString()));
|
||||
|
||||
auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status()));
|
||||
setReportStatusItemColor(statusItem, QString::fromStdString(report.status()));
|
||||
const QString rawStatus = 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, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name())));
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ QFont reportLogFont();
|
|||
|
||||
QString formatReportCategory(const QString &category);
|
||||
QString formatReportTime(qint64 secondsSinceEpoch);
|
||||
QString formatReportStatus(const QString &status);
|
||||
|
||||
void fillReportTableRow(QTableWidget *table,
|
||||
int row,
|
||||
|
|
@ -30,7 +31,8 @@ void fillReportTableRow(QTableWidget *table,
|
|||
int categoryColumn,
|
||||
int gameIdColumn,
|
||||
int statusColumn,
|
||||
int assignedColumn);
|
||||
int assignedColumn,
|
||||
bool colorizeStatus = true);
|
||||
|
||||
void renderReportDetails(QTextEdit *chatLogEdit,
|
||||
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
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 <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
|
||||
* model.
|
||||
* This widget provides a graphical control element for the CardFilter::Attr::AttrColor and
|
||||
* CardFilter::Attr::AttrColorIdentity filters applied to the filter model.
|
||||
* @param parent The Qt Widget that this widget will parent to
|
||||
* @param _filterModel The filter model that this widget will manipulate
|
||||
*/
|
||||
|
|
@ -59,11 +73,25 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi()
|
|||
modeComboBox->blockSignals(true);
|
||||
modeComboBox->clear();
|
||||
|
||||
modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch));
|
||||
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("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->setAccessibleName(tr("Color filter mode"));
|
||||
|
||||
// Restore current mode
|
||||
const int index = modeComboBox->findData(QVariant::fromValue(currentMode));
|
||||
|
|
@ -74,6 +102,28 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi()
|
|||
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)
|
||||
{
|
||||
if (active) {
|
||||
|
|
@ -85,74 +135,24 @@ void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, boo
|
|||
|
||||
void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color)
|
||||
{
|
||||
QString colorString = color;
|
||||
QString typeStr;
|
||||
FilterModeInfo info = getFilterModeInfo(currentMode);
|
||||
|
||||
// Remove previous filters
|
||||
removeFilters(filterModel, color, info.attr);
|
||||
|
||||
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
|
||||
QList<const CardFilter *> matchingFilters;
|
||||
|
||||
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;
|
||||
if (info.includeType != CardFilter::Type::TypeEnd) {
|
||||
filterModel->addFilter(new CardFilter(color, info.includeType, info.attr));
|
||||
}
|
||||
}
|
||||
|
||||
void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color)
|
||||
{
|
||||
QString colorString = color;
|
||||
FilterModeInfo info = getFilterModeInfo(currentMode);
|
||||
|
||||
// Remove inclusion filters
|
||||
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
|
||||
QList<const CardFilter *> matchingFilters;
|
||||
removeFilters(filterModel, color, info.attr);
|
||||
|
||||
for (const CardFilter *filter : allColorFilters) {
|
||||
if (filter->term() == color) {
|
||||
matchingFilters.append(filter);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// Add exclusion filters if the mode demands it.
|
||||
if (info.excludeType != CardFilter::Type::TypeEnd) {
|
||||
filterModel->addFilter(new CardFilter(color, info.excludeType, info.attr));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +169,7 @@ void VisualDatabaseDisplayColorFilterWidget::updateFilterMode()
|
|||
filterModel->filterTree()->blockSignals(true);
|
||||
|
||||
filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor);
|
||||
filterModel->clearFiltersOfType(CardFilter::Attr::AttrColorIdentity);
|
||||
|
||||
const QList<ManaSymbolWidget *> manaSymbolWidgets = findChildren<ManaSymbolWidget *>();
|
||||
|
||||
|
|
@ -211,52 +212,53 @@ QList<QChar> VisualDatabaseDisplayColorFilterWidget::getActiveColors()
|
|||
|
||||
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;
|
||||
for (const CardFilter *filter : allColorFilters) {
|
||||
if (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;
|
||||
}
|
||||
for (const CardFilter *filter : filters) {
|
||||
if (filter->type() == filterType && manaSymbolWidget->getSymbolChar() == filter->term()) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
setManaSymbolActive(manaSymbolWidget->getSymbolChar(), false);
|
||||
}
|
||||
manaSymbolWidget->setColorActive(useExclude ? !found : found);
|
||||
manaSymbolWidget->setDisabled(currentMode == FilterMode::Custom);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
#include <QByteArray>
|
||||
#include <QChar>
|
||||
#include <QList>
|
||||
#include <QMetaType>
|
||||
#include <QWidget>
|
||||
#include <libcockatrice/filters/filter_mode.h>
|
||||
#include <libcockatrice/utility/debouncer.h>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
|
|
@ -38,15 +38,6 @@ private:
|
|||
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
|
||||
{
|
||||
Q_OBJECT
|
||||
|
|
@ -54,11 +45,13 @@ class VisualDatabaseDisplayColorFilterWidget : public QWidget
|
|||
public:
|
||||
explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel);
|
||||
void retranslateUi();
|
||||
void setFilterMode(FilterMode filterMode);
|
||||
|
||||
signals:
|
||||
void filterModeChanged(FilterMode filterMode);
|
||||
|
||||
private slots:
|
||||
void addCustomFilterModeItem();
|
||||
void handleColorToggled(QChar color, bool active);
|
||||
void addFilter(QChar color);
|
||||
void removeFilter(QChar color);
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
|
|||
connect(clearFilterWidget, &QToolButton::clicked, this, [this] {
|
||||
filterModel->blockSignals(true);
|
||||
filterModel->filterTree()->blockSignals(true);
|
||||
colorFilterWidget->setFilterMode(FilterMode::Includes);
|
||||
filterModel->clear();
|
||||
filterModel->blockSignals(false);
|
||||
filterModel->filterTree()->blockSignals(false);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
#include <QElapsedTimer>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QFutureWatcher>
|
||||
#include <QFuture>
|
||||
#include <QMetaObject>
|
||||
#include <QMetaType>
|
||||
#include <QSet>
|
||||
|
|
@ -244,15 +244,15 @@ void VisualDeckStorageModel::startScan()
|
|||
|
||||
// The scan (directory walk + one stat per file) runs on a worker thread so that
|
||||
// constructing the widget never stalls the UI thread on a large deck folder.
|
||||
auto *watcher = new QFutureWatcher<DeckScanResult>(this);
|
||||
connect(watcher, &QFutureWatcher<DeckScanResult>::finished, this, [this, watcher, generation] {
|
||||
watcher->deleteLater();
|
||||
|
||||
// The continuation is bound to this model as its context object: it runs on the UI thread,
|
||||
// and Qt drops it if the model is destroyed first, so no result is delivered to a dead model.
|
||||
QFuture<DeckScanResult> scan =
|
||||
QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); });
|
||||
scan.then(this, [this, generation](const DeckScanResult &result) {
|
||||
if (generation != scanGeneration) {
|
||||
return; // A newer scan started while this one was running; drop the stale result.
|
||||
}
|
||||
|
||||
const DeckScanResult result = watcher->result();
|
||||
folderPaths = result.folderPaths;
|
||||
|
||||
if (result.decks.isEmpty()) {
|
||||
|
|
@ -268,9 +268,6 @@ void VisualDeckStorageModel::startScan()
|
|||
beginLoad(row);
|
||||
}
|
||||
});
|
||||
|
||||
watcher->setFuture(
|
||||
QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); }));
|
||||
}
|
||||
|
||||
void VisualDeckStorageModel::beginLoad(int row)
|
||||
|
|
@ -286,32 +283,7 @@ void VisualDeckStorageModel::beginLoad(int row)
|
|||
const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath);
|
||||
const int generation = scanGeneration;
|
||||
|
||||
auto *watcher = new QFutureWatcher<std::optional<DeckLoadResult>>(this);
|
||||
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> {
|
||||
auto load = QtConcurrent::run([filePath, fmt]() -> std::optional<DeckLoadResult> {
|
||||
std::optional<LoadedDeck> deck = DeckLoader::loadFromFile(filePath, fmt, false);
|
||||
if (!deck) {
|
||||
return std::nullopt;
|
||||
|
|
@ -320,7 +292,28 @@ void VisualDeckStorageModel::beginLoad(int row)
|
|||
// keep the completion handler on the UI thread cheap.
|
||||
const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query());
|
||||
return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity};
|
||||
}));
|
||||
});
|
||||
|
||||
// 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()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
## Building Doxygen Documentation
|
||||
|
||||
We use [Doxygen](https://www.doxygen.nl) for our code documentation.
|
||||
After installing the tool, you can build a HTML webpage into the `/docs` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile):
|
||||
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
|
||||
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)
|
||||
|
||||
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>
|
||||
|
||||
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.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue