mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 01:23:24 -08:00
Compare commits
26 commits
159485bc46
...
e87cd80c9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87cd80c9b | ||
|
|
ed119cc940 | ||
|
|
a791389c6a | ||
|
|
13e7a3baaf | ||
|
|
3bcdfc3793 | ||
|
|
396196b762 | ||
|
|
7a40ed9079 | ||
|
|
634f8115a5 | ||
|
|
54797452da | ||
|
|
d8700b2947 | ||
|
|
140fbb4484 | ||
|
|
3f7aa4ca82 | ||
|
|
75beb7f302 | ||
|
|
99422c2bf0 | ||
|
|
96f8c3a423 | ||
|
|
3fa8d78786 | ||
|
|
34201122ec | ||
|
|
d2abd645e8 | ||
|
|
ee57d740c2 | ||
|
|
2e0a718cd7 | ||
|
|
28597ef716 | ||
|
|
75d7764643 | ||
|
|
81a0b97240 | ||
|
|
624163916f | ||
|
|
94f7de42f2 | ||
|
|
7768416091 |
167 changed files with 5400 additions and 503 deletions
|
|
@ -175,9 +175,9 @@ fi
|
|||
if [[ $MAKE_SANITIZE ]]; then
|
||||
flags+=("-DENABLE_SANITIZERS=ON")
|
||||
else
|
||||
# The release-package step re-runs cmake in the same build dir, so an
|
||||
# ENABLE_SANITIZERS=ON left over from the debug step would otherwise leak
|
||||
# into the shipped binaries (libasan/libubsan Depends in the DEB).
|
||||
# Every leg states the flag explicitly rather than relying on the CMake
|
||||
# default, so a build directory reused from a sanitized configuration can
|
||||
# never leak libasan/libubsan into a shipped binary.
|
||||
flags+=("-DENABLE_SANITIZERS=OFF")
|
||||
fi
|
||||
if [[ $MAKE_COVERAGE ]]; then
|
||||
|
|
|
|||
228
.github/workflows/desktop-build.yml
vendored
228
.github/workflows/desktop-build.yml
vendored
|
|
@ -33,10 +33,18 @@ on:
|
|||
- 'CMakeLists.txt'
|
||||
- 'vcpkg.json'
|
||||
- 'vcpkg' # needed to match submodule bumps (gitlink)
|
||||
# The legs the pull-request matrix leaves out run here instead, so the legacy distros and
|
||||
# the slow macOS runner stay covered without gating a pull request on them. Tags still
|
||||
# build every leg, because a tag is what ships.
|
||||
schedule:
|
||||
- cron: '23 3 * * *'
|
||||
|
||||
# Cancel earlier, unfinished runs of this workflow on the same branch (unless on tag --> release)
|
||||
concurrency:
|
||||
group: "${{ github.workflow }} @ ${{ github.ref_name }}"
|
||||
# The event name is part of the group because a nightly run and a push run on master share
|
||||
# refs/heads/master but must not cancel each other: the nightly would drop the push's vcpkg
|
||||
# import, and a merge landing during the nightly would drop that night's coverage.
|
||||
group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})"
|
||||
cancel-in-progress: ${{ github.ref_type != 'tag' }}
|
||||
|
||||
jobs:
|
||||
|
|
@ -141,6 +149,7 @@ jobs:
|
|||
|
||||
allow-failure: yes
|
||||
package: skip # We are packaged in Arch already
|
||||
debug_test: yes # see the note on Fedora 44 below
|
||||
|
||||
- distro: Servatrice_Debian
|
||||
version: 12
|
||||
|
|
@ -149,59 +158,47 @@ jobs:
|
|||
server_only: yes
|
||||
test: skip
|
||||
|
||||
- distro: Debian
|
||||
version: 12
|
||||
|
||||
package: DEB
|
||||
test: skip # Running tests on all distros is superfluous
|
||||
|
||||
- distro: Debian
|
||||
version: 13
|
||||
|
||||
package: DEB
|
||||
|
||||
- distro: Fedora
|
||||
version: 43
|
||||
|
||||
package: RPM
|
||||
test: skip # Running tests on all distros is superfluous
|
||||
|
||||
- distro: Fedora
|
||||
version: 44
|
||||
|
||||
package: RPM
|
||||
|
||||
- distro: Ubuntu
|
||||
version: 24.04
|
||||
|
||||
package: DEB
|
||||
test: skip # Running tests on all distros is superfluous
|
||||
# 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
|
||||
|
||||
- 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.
|
||||
sanitize: yes
|
||||
|
||||
# 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: cover # separate image and ccache, same distro
|
||||
variant: sanitize # separate image and ccache, same distro
|
||||
|
||||
# The one leg that runs instrumented for line coverage and publishes the report as
|
||||
# a workflow artifact. It is a separate leg rather than a second flag on the
|
||||
# sanitizer leg because the two instruments answer different questions: the
|
||||
# sanitizer leg reports what the run did wrong, the coverage leg reports what the
|
||||
# run never touched at all. Combining them would also slow both down. Packaging is
|
||||
# skipped because the leg above already publishes the DEB for this distro.
|
||||
coverage: yes
|
||||
package: skip
|
||||
sanitize: yes
|
||||
|
||||
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
|
||||
needs: configure
|
||||
# The nightly legs live in build-linux-nightly below. A leg is in exactly one of the two
|
||||
# matrices, so nothing that reviews a change waits for a distro nobody is changing for.
|
||||
if: github.event_name != 'schedule'
|
||||
runs-on: ubuntu-latest # https://github.com/actions/runner-images
|
||||
continue-on-error: ${{ matrix.allow-failure == 'yes' }}
|
||||
timeout-minutes: 70
|
||||
|
|
@ -212,8 +209,18 @@ jobs:
|
|||
CMAKE_GENERATOR: 'Ninja'
|
||||
NAME: ${{ matrix.distro }}${{ matrix.version }}
|
||||
IMAGE_SUFFIX: ${{ matrix.variant }}
|
||||
# Which build the suite runs in, decided once per leg here so the steps' if: conditions
|
||||
# and their shells cannot drift apart. A step's own env is not visible to its if:, which
|
||||
# is why these live at job level rather than on the release step.
|
||||
# DEBUG_TEST the instrumented legs (symbols and instrumentation matter) and the two
|
||||
# newest distro toolchains, which only fail on a warning in Debug.
|
||||
# RUN_TESTS every other leg that tests runs the suite against the release build it may
|
||||
# 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') }}
|
||||
|
||||
steps:
|
||||
# Shared with build-linux-nightly so the two leg sets run the same steps.
|
||||
steps: &linux-steps
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v7
|
||||
|
||||
|
|
@ -228,8 +235,11 @@ jobs:
|
|||
shell: bash
|
||||
run: source .ci/docker.sh --build
|
||||
|
||||
# The instrumented legs and the two newest distro toolchains build and test Debug; every
|
||||
# other leg runs its suite against the release build below, so testing and packaging cost
|
||||
# one compile instead of two.
|
||||
- name: "Build debug and test"
|
||||
if: matrix.test != 'skip'
|
||||
if: env.DEBUG_TEST == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
sanitize: '${{ matrix.sanitize }}'
|
||||
|
|
@ -269,15 +279,23 @@ jobs:
|
|||
path: coverage/
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: "Build release package"
|
||||
# One build serves both purposes: the suite runs against the same configuration that
|
||||
# gets packaged, so a packaging leg no longer pays for a full Debug compile it then
|
||||
# throws away. macOS and Windows have always tested their release build.
|
||||
#
|
||||
# Instrumented and debug-test legs reach this step only when they also package; their
|
||||
# suite already ran in the debug step above, so the tests are not repeated here.
|
||||
- name: "Build release, test and package"
|
||||
id: build
|
||||
if: matrix.package != 'skip'
|
||||
if: matrix.package != 'skip' || env.RUN_TESTS == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
SUFFIX: '-${{ matrix.distro }}${{ matrix.version }}'
|
||||
package: '${{ matrix.package }}'
|
||||
server_only: '${{ matrix.server_only }}'
|
||||
WITH_CRASH_REPORTING: 'ON'
|
||||
# Only a leg that packages builds the shipping configuration; a release build that
|
||||
# only runs tests stays free of the crash reporting SDK.
|
||||
WITH_CRASH_REPORTING: ${{ matrix.package != 'skip' && 'ON' || '' }}
|
||||
# Tag builds only: artifacts from master pushes and PR branches must not report to
|
||||
# Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way.
|
||||
# configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN
|
||||
|
|
@ -287,6 +305,7 @@ jobs:
|
|||
source .ci/docker.sh
|
||||
args=()
|
||||
[[ $server_only == yes ]] && args+=(--no-client)
|
||||
[[ $RUN_TESTS == true ]] && args+=(--test)
|
||||
[[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE")
|
||||
args+=(--ccache "$CCACHE_SIZE")
|
||||
args+=(--cmake-generator "$CMAKE_GENERATOR")
|
||||
|
|
@ -296,7 +315,14 @@ jobs:
|
|||
# configures, and empty keeps the mock fallback.
|
||||
export RUN_ARGS="${RUN_ARGS:-} -e COCKATRICE_SENTRY_DSN"
|
||||
|
||||
RUN --server --release --package "$package" "${args[@]}"
|
||||
# --package takes an optional type, so the flag and its argument have to travel
|
||||
# together or the type would become the next flag.
|
||||
package_args=()
|
||||
[[ -n $package && $package != skip ]] && package_args+=(--package "$package")
|
||||
|
||||
# compile.sh runs the tests before the package target, so a failing suite stops the
|
||||
# leg before anything is built into an installer.
|
||||
RUN --server --release "${package_args[@]}" "${args[@]}"
|
||||
|
||||
- name: "Install sentry-cli"
|
||||
if: matrix.package != 'skip' && needs.configure.outputs.tag != null
|
||||
|
|
@ -398,30 +424,72 @@ jobs:
|
|||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
|
||||
|
||||
build-linux-nightly:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- distro: Debian
|
||||
version: 12 # previous stable, still covered by the nightly
|
||||
|
||||
package: DEB
|
||||
test: skip # Running tests on all distros is superfluous
|
||||
|
||||
- distro: Fedora
|
||||
version: 43 # previous release, still covered by the nightly
|
||||
|
||||
package: RPM
|
||||
test: skip # Running tests on all distros is superfluous
|
||||
|
||||
- distro: Ubuntu
|
||||
version: 24.04 # previous LTS, still covered by the nightly
|
||||
|
||||
package: DEB
|
||||
test: skip # Running tests on all distros is superfluous
|
||||
|
||||
# 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
|
||||
# sanitizer leg reports what the run did wrong, the coverage leg reports what the
|
||||
# run never touched at all. Combining them would also slow both down.
|
||||
#
|
||||
# It moved here from build-linux: gcovr.cfg sets no fail_under, so the leg cannot
|
||||
# fail a build, and nothing consumes the report per change, which made it an
|
||||
# artifact nobody read on every pull request. On the default branch it becomes a
|
||||
# trend instead. Image and ccache keys come from distro, version and variant, so
|
||||
# they are unchanged and the warm cache that leg built still matches.
|
||||
- distro: Ubuntu
|
||||
version: 26.04
|
||||
variant: cover # separate image and ccache, same distro
|
||||
|
||||
coverage: yes
|
||||
package: skip
|
||||
# A report must not redden the nightly run: vcpkg-cache-import only imports the
|
||||
# archives of a run whose conclusion is success.
|
||||
allow-failure: yes
|
||||
|
||||
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.
|
||||
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' }}
|
||||
timeout-minutes: 70
|
||||
env:
|
||||
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache
|
||||
CCACHE_EVICTION_AGE: 7d
|
||||
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
|
||||
CMAKE_GENERATOR: 'Ninja'
|
||||
NAME: ${{ matrix.distro }}${{ matrix.version }}
|
||||
IMAGE_SUFFIX: ${{ matrix.variant }}
|
||||
steps: *linux-steps
|
||||
|
||||
build-vcpkg:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macOS
|
||||
target: 13 # EOL 2025-09-15
|
||||
runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md
|
||||
# macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout
|
||||
|
||||
ccache_eviction_age: 7d
|
||||
cmake_generator: Ninja
|
||||
make_package: 1
|
||||
override_target: 13
|
||||
package_suffix: "-macOS13_Intel"
|
||||
qt_version: 6.11.*
|
||||
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
|
||||
xcode: "26.3"
|
||||
|
||||
- os: macOS
|
||||
target: 14 # EOL 2026-??
|
||||
runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md
|
||||
|
|
@ -497,13 +565,16 @@ jobs:
|
|||
|
||||
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
|
||||
needs: [configure, vcpkg-baseline]
|
||||
# The nightly leg lives in build-vcpkg-nightly below, next to the Linux nightly legs.
|
||||
if: github.event_name != 'schedule'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 100
|
||||
env:
|
||||
CCACHE_DIR: ${{ github.workspace }}/.cache/
|
||||
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
|
||||
|
||||
steps:
|
||||
# Shared with build-vcpkg-nightly so the two leg sets run the same steps.
|
||||
steps: &vcpkg-steps
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
|
|
@ -978,6 +1049,42 @@ jobs:
|
|||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
|
||||
|
||||
build-vcpkg-nightly:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macOS
|
||||
target: 13 # EOL 2025-09-15
|
||||
runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md
|
||||
# macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout
|
||||
|
||||
ccache_eviction_age: 7d
|
||||
cmake_generator: Ninja
|
||||
make_package: 1
|
||||
override_target: 13
|
||||
package_suffix: "-macOS13_Intel"
|
||||
qt_version: 6.11.*
|
||||
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
|
||||
xcode: "26.3"
|
||||
|
||||
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
|
||||
needs: configure
|
||||
# The EOL target on the slowest runner is too slow to gate a change, so it runs in the
|
||||
# nightly and on tags only, where the vcpkg cache import also has to accept schedule runs.
|
||||
if: github.event_name == 'schedule' || github.ref_type == 'tag'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 100
|
||||
env:
|
||||
CCACHE_DIR: ${{ github.workspace }}/.cache/
|
||||
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
|
||||
steps: *vcpkg-steps
|
||||
|
||||
prune-caches:
|
||||
name: Prune caches
|
||||
# Only master writes caches at all, so only master may clean them up too
|
||||
|
|
@ -996,8 +1103,9 @@ jobs:
|
|||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
# Must list every vcpkg_scope of the build-vcpkg matrix above, or the entries of
|
||||
# a scope that is still in use get deleted on the next run.
|
||||
# Must list every vcpkg_scope of the build-vcpkg and build-vcpkg-nightly
|
||||
# matrices above, or the entries of a scope that is still in use get deleted
|
||||
# on the next run.
|
||||
LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/'
|
||||
run: |
|
||||
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \
|
||||
|
|
|
|||
16
.github/workflows/vcpkg-cache-import.yml
vendored
16
.github/workflows/vcpkg-cache-import.yml
vendored
|
|
@ -4,12 +4,14 @@ name: Import vcpkg cache
|
|||
# rebuilds protobuf, openssl and abseil from source and saves them into the shared
|
||||
# cache, where every later run can restore them.
|
||||
#
|
||||
# Only master pushes are imported here: the collect step, keys.txt and the zip
|
||||
# contents all come from the workflow run being imported, so importing from an
|
||||
# unmerged pull request would let anyone who can push a branch — or a bot actor
|
||||
# such as Dependabot, whose PRs are same-repo — promote arbitrary binaries into
|
||||
# the scope that master and tag builds restore and link into signed releases.
|
||||
# Forks build untrusted code and must never reach that scope either.
|
||||
# Only master pushes and the nightly schedule run on master are imported here: the
|
||||
# collect step, keys.txt and the zip contents all come from the workflow run being
|
||||
# imported, so importing from an unmerged pull request would let anyone who can push
|
||||
# a branch — or a bot actor such as Dependabot, whose PRs are same-repo — promote
|
||||
# arbitrary binaries into the scope that master and tag builds restore and link into
|
||||
# signed releases. Forks build untrusted code and must never reach that scope either.
|
||||
# A scheduled run is the default branch at the commit a master push already built, so
|
||||
# it carries the same trust as a push.
|
||||
#
|
||||
# repository_dispatch is not used on purpose: it would also resolve to the default branch,
|
||||
# but it counts as a low-trust trigger and may only write there when it declares a
|
||||
|
|
@ -38,7 +40,7 @@ jobs:
|
|||
# the shared scope even if they somehow target the default branch.
|
||||
if: >-
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
(github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') &&
|
||||
github.event.workflow_run.head_branch == 'master' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository &&
|
||||
!endsWith(github.event.workflow_run.actor.login, '[bot]') &&
|
||||
|
|
|
|||
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -4,3 +4,6 @@
|
|||
[submodule "doxygen-awesome-css"]
|
||||
path = doc/doxygen/theme
|
||||
url = https://github.com/jothepro/doxygen-awesome-css.git
|
||||
[submodule "external/qttesting"]
|
||||
path = external/qttesting
|
||||
url = https://github.com/Kitware/QtTesting.git
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ option(WITH_CLIENT "Build Cockatrice client" ON)
|
|||
option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON)
|
||||
# Compile Servatrice
|
||||
option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF)
|
||||
# Compile GUI tests (QtTesting widget-level playback)
|
||||
option(WITH_GUI_TEST "Build the QtTesting-based GUI test harness" OFF)
|
||||
# Compile tests
|
||||
option(TEST "Build tests" OFF)
|
||||
# Build with AddressSanitizer and UndefinedBehaviorSanitizer
|
||||
|
|
@ -29,6 +31,14 @@ option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF)
|
|||
# Use vcpkg regardless of OS
|
||||
option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
|
||||
|
||||
# The GUI test harness lives in a submodule; a checkout without it must still configure.
|
||||
if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/CMakeLists.txt")
|
||||
message(WARNING "WITH_GUI_TEST is ON but external/qttesting is missing, so GUI tests are disabled.\n"
|
||||
"Run 'git submodule update --init external/qttesting' to enable them."
|
||||
)
|
||||
set(WITH_GUI_TEST OFF)
|
||||
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)
|
||||
|
|
@ -463,6 +473,34 @@ if(WITH_ORACLE)
|
|||
set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS})
|
||||
endif()
|
||||
|
||||
if(WITH_GUI_TEST)
|
||||
set(QtTesting_QT_VERSION "6")
|
||||
# QtTesting registers its own unit tests when BUILD_TESTING is on. They target the
|
||||
# Qt version the harness was written for and fail on Qt 6.11 (toolbutton
|
||||
# long-press recording), and they are not Cockatrice's tests, so suppress them
|
||||
# for the duration of the subdirectory instead of painting ctest red.
|
||||
if(DEFINED BUILD_TESTING)
|
||||
set(_cockatrice_saved_build_testing ${BUILD_TESTING})
|
||||
endif()
|
||||
set(BUILD_TESTING
|
||||
OFF
|
||||
CACHE BOOL "Build the testing tree." FORCE
|
||||
)
|
||||
add_subdirectory(external/qttesting)
|
||||
if(DEFINED _cockatrice_saved_build_testing)
|
||||
set(BUILD_TESTING
|
||||
${_cockatrice_saved_build_testing}
|
||||
CACHE BOOL "Build the testing tree." FORCE
|
||||
)
|
||||
else()
|
||||
unset(BUILD_TESTING CACHE)
|
||||
endif()
|
||||
# cl.exe has no warnings-as-errors here, and it rejects -Wno-error with D8021.
|
||||
if(NOT MSVC)
|
||||
target_compile_options(qttesting PRIVATE -Wno-error)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(TEST)
|
||||
include(CTest)
|
||||
add_subdirectory(tests)
|
||||
|
|
|
|||
66
Testing/README.md
Normal file
66
Testing/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
# GUI tests
|
||||
|
||||
Widget-level playback tests for the Cockatrice client, driven by
|
||||
[QtTesting](https://github.com/Kitware/QtTesting) (`external/qttesting`, a submodule).
|
||||
Each `*_test.xml` file is a list of events that QtTesting replays against the running
|
||||
application; a test passes when every event is handled without error.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
git submodule update --init external/qttesting # once
|
||||
cmake -S . -B build/debug -DCMAKE_BUILD_TYPE=Debug -DWITH_SERVER=0 -DWITH_GUI_TEST=ON
|
||||
cmake --build build/debug -j 24
|
||||
|
||||
./Testing/run_test.sh --all # headless, offscreen
|
||||
./Testing/run_test.sh Testing/smoke_test.xml # one file
|
||||
./Testing/run_test.sh --visual Testing/smoke_test.xml # shows the window
|
||||
```
|
||||
|
||||
`WITH_GUI_TEST` is on by default; a checkout without the submodule configures with a
|
||||
warning and no harness. `COCKATRICE_BIN` points the runner at another build, and
|
||||
`TEST_TIMEOUT` (seconds, default 180) bounds a single test — a failing event inside a
|
||||
modal dialog can otherwise leave the process alive. The client also hard-exits a few
|
||||
seconds after playback returns, whatever the result.
|
||||
|
||||
The tests are **not** part of `ctest`: they need a card database and a developer's
|
||||
settings, so they stay a local loop for now.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Not hermetic.** The client reads the local settings and card database, so a test can
|
||||
pass on one machine and fail on another. Avoid asserting things that restored
|
||||
workspace state or user preferences decide (for example the number of open tabs).
|
||||
- **English labels only.** Menu entries are addressed by their (translated) text, so the
|
||||
tests expect the source language. Widgets are addressed by objectName paths, which are
|
||||
stable across translations.
|
||||
- Actions that open a dialog are followed by a `pause` event: playback continues in the
|
||||
dialog's own event loop, and the pause gives the UI time to build itself.
|
||||
- Widget paths include Qt-internal names where a QTabWidget is involved
|
||||
(`qt_tabwidget_stackedwidget`), and the deck views in a game are indexed
|
||||
(`tabbedDeckView_0`, `tabbedDeckView_1`).
|
||||
|
||||
## Writing a test
|
||||
|
||||
A test is a `<events>` list. Widgets are named by their path from the main window, e.g.
|
||||
`MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu`. Useful commands:
|
||||
|
||||
| Command | Arguments | Effect |
|
||||
|---------|-----------|--------|
|
||||
| `activate` | action or menu name | triggers a QAction (menus are opened first) |
|
||||
| `key` | Qt key code | presses that key on the focused widget |
|
||||
| `pause` | milliseconds | waits, letting the UI settle |
|
||||
| `clickCard` | `card,zone` | double click the card found in that zone (plays it from hand) |
|
||||
| `flipCard` | `card,zone` | right-click → *Turn Over* |
|
||||
| `cardAction` | `card,zone,label` | right-click → first menu entry whose label contains `label` (submenus included) |
|
||||
| `moveCard` | `card,srcZone,dstZone` | drags the card onto the destination zone |
|
||||
| `drawArrow` | `srcCard,srcZone,dstCard,dstZone` | right-drag an arrow between two cards |
|
||||
| `createToken` | `name,color,pt` | creates a token as the active local player |
|
||||
| `selectItem` | `item label` | selects the tree/list entry with that label |
|
||||
| `typeText` | `text` | types text one key event at a time |
|
||||
| `expectProperty` | `property,value` | fails playback unless the widget property matches |
|
||||
|
||||
The card, item and property commands are `pqCockatriceEventPlayer`
|
||||
(`cockatrice/src/game_graphics/pq_cockatrice_event_player.h`). They resolve cards by name
|
||||
and zone through the `QGraphicsScene` and send real mouse events at the card's current
|
||||
viewport position, so a test does not depend on the window size or screen resolution.
|
||||
69
Testing/card_actions_test.xml
Normal file
69
Testing/card_actions_test.xml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<events>
|
||||
<!--
|
||||
Card actions (custom commands of pqCockatriceEventPlayer).
|
||||
|
||||
Exercises the context menu path (tap, counters in a submenu, flip),
|
||||
arrow drawing, and dragging a card between zones. All of it through
|
||||
mouse events on the game view, so the same file runs at any resolution.
|
||||
-->
|
||||
|
||||
<!-- ===== Two-player local game ===== -->
|
||||
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="Start &local game..." />
|
||||
<event widget="MainWindow/localGameDialog/generalGroupBox/numberPlayersEdit" command="set_int" arguments="2" />
|
||||
<event widget="MainWindow/localGameDialog" command="key" arguments="16777220" />
|
||||
<event widget="MainWindow" command="pause" arguments="1000" />
|
||||
|
||||
<!-- ===== Player 1 loads a one-card deck ===== -->
|
||||
<!-- (No tab-count assertion: the restored workspace state decides how many other
|
||||
tabs exist, and the paths below already prove the game tab is there.) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="1 Lightning Bolt" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
|
||||
<!-- ===== Player 2 loads the same deck and readies up ===== -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="1 Lightning Bolt" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
|
||||
<event widget="MainWindow" command="pause" arguments="1000" />
|
||||
|
||||
<!-- ===== Player 1, precombat main ===== -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
|
||||
<!-- ===== A token to interact with ===== -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="createToken" arguments="Goblin,r,1/1" />
|
||||
<event widget="MainWindow" command="pause" arguments="1000" />
|
||||
|
||||
<!-- Tap it through the context menu -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="cardAction" arguments="Goblin,table,Tap / Untap" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
|
||||
<!-- Add a counter: the entry lives in the "Card counters" submenu -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="cardAction" arguments="Goblin,table,Add counter (A)" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
|
||||
<!-- Flip it face down -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="flipCard" arguments="Goblin,table" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
|
||||
<!-- ===== An arrow from a second token to the Goblin ===== -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="createToken" arguments="Elf,w,1/1" />
|
||||
<event widget="MainWindow" command="pause" arguments="1000" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="drawArrow" arguments="Elf,table,Goblin,table" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
|
||||
<!-- ===== Drag the Goblin into the graveyard ===== -->
|
||||
<!-- A token that leaves the battlefield is destroyed rather than moved, so
|
||||
there is nothing to drag back afterwards. -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="moveCard" arguments="Goblin,table,grave" />
|
||||
<event widget="MainWindow" command="pause" arguments="1000" />
|
||||
</events>
|
||||
4
Testing/deck_editor_open_test.xml
Normal file
4
Testing/deck_editor_open_test.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<events>
|
||||
<!-- Open a new deck editor tab from the home page -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
|
||||
</events>
|
||||
14
Testing/deck_editor_search_test.xml
Normal file
14
Testing/deck_editor_search_test.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<events>
|
||||
<!-- Open a new visual deck editor tab from the home page -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
|
||||
<!-- Type "Lightning" into the search box (keycodes for L-i-g-h-t-n-i-n-g) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="76" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="105" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="103" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="104" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="116" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="110" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="105" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="110" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="103" />
|
||||
</events>
|
||||
11
Testing/dialog_lifecycle_test.xml
Normal file
11
Testing/dialog_lifecycle_test.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<events>
|
||||
<!-- Open and close Settings dialog (modal exec) -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
|
||||
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
|
||||
<event widget="MainWindow/settingsDialog" command="key" arguments="16777216" />
|
||||
|
||||
<!-- Open and close About dialog (modal exec) -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
|
||||
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&About Cockatrice" />
|
||||
<event widget="MainWindow/aboutDialog" command="key" arguments="16777216" />
|
||||
</events>
|
||||
101
Testing/game_mechanics_test.xml
Normal file
101
Testing/game_mechanics_test.xml
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<events>
|
||||
<!--
|
||||
Tier 7: Game Mechanics
|
||||
Tests: play cards (clickCard), flip cards (flipCard),
|
||||
create tokens (createToken), draw arrows (drawArrow).
|
||||
All interactions are pure UI mouse events through the QGraphicsView viewport.
|
||||
-->
|
||||
|
||||
<!-- ===== Start a local 2-player game ===== -->
|
||||
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="Start &local game..." />
|
||||
<!-- Force two players so both deck views exist, independent of the user's settings -->
|
||||
<event widget="MainWindow/localGameDialog/generalGroupBox/numberPlayersEdit" command="set_int" arguments="2" />
|
||||
<event widget="MainWindow/localGameDialog" command="key" arguments="16777220" />
|
||||
|
||||
<!-- ===== Player 1: Load 3-card deck ===== -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="keyEvent" arguments="6,65,67108864,,0,1" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="3 Lightning Bolt" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
|
||||
<!-- ===== Player 2: Load same deck ===== -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="keyEvent" arguments="6,65,67108864,,0,1" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="3 Lightning Bolt" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
|
||||
<event widget="MainWindow" command="pause" arguments="1000" />
|
||||
|
||||
<!-- ===== Player 1, Turn 1 ===== -->
|
||||
<!-- Upkeep → Draw → Main 1 -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
|
||||
<!-- Create a Goblin token -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="createToken" arguments="Goblin,r,1/1" />
|
||||
<event widget="MainWindow" command="pause" arguments="1000" />
|
||||
|
||||
<!-- Create a second token (Elf) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="createToken" arguments="Elf,w,1/1" />
|
||||
<event widget="MainWindow" command="pause" arguments="1000" />
|
||||
|
||||
<!-- Flip the Goblin (right-click → Turn Over) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="flipCard" arguments="Goblin,table" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
|
||||
<!-- Play a Lightning Bolt from hand (double-click) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="clickCard" arguments="Lightning Bolt,hand" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" -->
|
||||
|
||||
<!-- Draw arrow from Elf to Goblin (right-drag) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="drawArrow" arguments="Elf,table,Goblin,table" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
|
||||
<!-- Advance through all remaining phases to end turn -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="200" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="200" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="200" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="200" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="200" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="200" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
|
||||
<!-- ===== Player 2, Turn 1 ===== -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
<!-- Advance through all phases to end turn -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="200" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="200" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="200" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="200" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="200" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="200" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
|
||||
</events>
|
||||
45
Testing/integration_test.xml
Normal file
45
Testing/integration_test.xml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<events>
|
||||
<!-- Integration test: exercises multiple tiers in sequence -->
|
||||
|
||||
<!-- Tier 2: Open and close all menus -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
|
||||
<event widget="MainWindow" command="key" arguments="16777216" />
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="dbMenu" />
|
||||
<event widget="MainWindow" command="key" arguments="16777216" />
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="tabsMenu" />
|
||||
<event widget="MainWindow" command="key" arguments="16777216" />
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
|
||||
<event widget="MainWindow" command="key" arguments="16777216" />
|
||||
|
||||
<!-- Tier 2: Open and close About (modal) -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
|
||||
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&About Cockatrice" />
|
||||
<event widget="MainWindow/aboutDialog" command="key" arguments="16777216" />
|
||||
|
||||
<!-- Tier 4: Open and close ViewLog (non-modal) -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
|
||||
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="View &Debug Log" />
|
||||
<event widget="MainWindow/viewLogDialog" command="key" arguments="16777216" />
|
||||
|
||||
<!-- Tier 4: Open and close Tip of the Day (non-modal) -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
|
||||
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&Tip of the Day" />
|
||||
<event widget="MainWindow/tipOfTheDayDialog" command="key" arguments="16777216" />
|
||||
|
||||
<!-- Tier 5: Settings search (state interaction) -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
|
||||
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
|
||||
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="100" />
|
||||
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="101" />
|
||||
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="98" />
|
||||
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="117" />
|
||||
<event widget="MainWindow/settingsDialog/settingsOkButton" command="activate" arguments="" />
|
||||
|
||||
<!-- Tier 3: Open deck editor from home and type in search -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabHome/homeWidget/homeButtonsBox/homeCreateNewDeckButton" command="activate" arguments="" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="76" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="105" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="103" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="104" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabDeckEditorVisual/databaseDisplayDock/databaseDisplayDockContents/databaseDisplayWidget/searchEdit" command="key" arguments="116" />
|
||||
</events>
|
||||
178
Testing/local_game_lifecycle_test.xml
Normal file
178
Testing/local_game_lifecycle_test.xml
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<events>
|
||||
<!--
|
||||
Tier 6: Local Game Lifecycle (Extended)
|
||||
Tests: create game -> load decks -> ready up -> game starts
|
||||
-> play through turns using menu activate commands
|
||||
|
||||
Phase actions triggered via activate on phasesMenu:
|
||||
aNextPhaseAction advances phase + triggers phase action (e.g., draw at draw step)
|
||||
aNextTurn in gameMenu triggers next turn
|
||||
|
||||
This avoids keyboard shortcut issues in offscreen/test environments.
|
||||
-->
|
||||
|
||||
<!-- ===== Phase 1: Start a local game via main menu ===== -->
|
||||
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="Start &local game..." />
|
||||
<!-- Force two players so both deck views exist, independent of the user's settings -->
|
||||
<event widget="MainWindow/localGameDialog/generalGroupBox/numberPlayersEdit" command="set_int" arguments="2" />
|
||||
<event widget="MainWindow/localGameDialog" command="key" arguments="16777220" />
|
||||
|
||||
<!-- ===== Phase 2: Player 1 loads deck from clipboard ===== -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="keyEvent" arguments="6,65,67108864,,0,1" />
|
||||
<!-- 1 Lightning Bolt -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="1 Lightning Bolt" />
|
||||
<!-- Ctrl+Return to accept clipboard dialog -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
|
||||
<!-- Delay -->
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Player 1 ready up -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_0/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
|
||||
<!-- ===== Phase 3: Player 2 loads deck from clipboard ===== -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/loadFromClipboardButton" command="activate" arguments="" />
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="keyEvent" arguments="6,65,67108864,,0,1" />
|
||||
<!-- 1 Lightning Bolt -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit/contentsEdit" command="typeText" arguments="1 Lightning Bolt" />
|
||||
<!-- Ctrl+Return to accept clipboard dialog -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/dlgDeckTextEdit" command="keyEvent" arguments="6,16777220,67108864,,0,1" />
|
||||
<!-- Delay -->
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Player 2 ready up -> game auto-starts -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/deckViewContainerWidget/tabbedDeckView_1/qt_tabwidget_stackedwidget/deckViewContainer/readyStartButton" command="activate" arguments="" />
|
||||
<!-- Delay for game to start -->
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
|
||||
<!-- ===== Phase 4: Advance through all 11 phases via phasesMenu activate ===== -->
|
||||
<!-- Using activate on phasesMenu triggers aNextPhaseAction which:
|
||||
- advances the phase by 1
|
||||
- calls triggerPhaseAction (e.g., draw card at draw step, untap all at untap) -->
|
||||
|
||||
<!-- Phase 0 (Untap) → 1 (Upkeep) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 1 (Upkeep) → 2 (Draw): TRIGGERS DRAW — Player 1 draws 1 card -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
<!-- Phase 2 (Draw) → 3 (Main 1) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
|
||||
<!-- ===== Main 1: Play a card from hand by double-click ===== -->
|
||||
<!-- clickCard resolves card by name in the scene, maps to viewport coords at current window size. -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/mainWidget/gamePlayAreaWidget/gameView" command="clickCard" arguments="Lightning Bolt,hand" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
|
||||
<!-- Phase 3 → 4 (Begin Combat) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
<!-- Phase 4 → 5 (Declare Attackers) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 5 → 6 (Declare Blockers) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 6 → 7 (Combat Damage) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 7 → 8 (End Combat) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 8 → 9 (Main 2) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 9 → 10 (Cleanup) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 10 → 0 (Untap): TRIGGERS NEXT TURN + UNTAP ALL — now Player 2's turn -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<!-- Delay for turn transition -->
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
|
||||
<!-- ===== Phase 5: Player 2's turn — advance through all 11 phases ===== -->
|
||||
<!-- Phase 0 (Untap) → 1 (Upkeep) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 1 (Upkeep) → 2 (Draw): TRIGGERS DRAW — Player 2 draws 1 card -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 2 (Draw) → 3 (Main 1) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 3 → 4 (Begin Combat) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 4 → 5 (Declare Attackers) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 5 → 6 (Declare Blockers) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 6 → 7 (Combat Damage) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 7 → 8 (End Combat) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 8 → 9 (Main 2) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 9 → 10 (Cleanup) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 10 → 0 (Untap): TRIGGERS NEXT TURN — back to Player 1 -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<!-- Delay -->
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
|
||||
<!-- ===== Phase 6: Player 1 turn 2 — advance to draw again ===== -->
|
||||
<!-- Phase 0 (Untap) → 1 (Upkeep) -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<!-- Phase 1 (Upkeep) → 2 (Draw): TRIGGERS DRAW — Player 1 draws again -->
|
||||
<event widget="MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu" command="activate" arguments="aNextPhaseAction" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
<event widget="MainWindow" command="key" arguments="32" />
|
||||
|
||||
</events>
|
||||
19
Testing/menu_open_close_test.xml
Normal file
19
Testing/menu_open_close_test.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<events>
|
||||
<!-- Test 1: Open and close each top-level menu -->
|
||||
|
||||
<!-- Cockatrice menu -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
|
||||
<event widget="MainWindow" command="key" arguments="16777216" />
|
||||
|
||||
<!-- Database menu -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="dbMenu" />
|
||||
<event widget="MainWindow" command="key" arguments="16777216" />
|
||||
|
||||
<!-- Tabs menu -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="tabsMenu" />
|
||||
<event widget="MainWindow" command="key" arguments="16777216" />
|
||||
|
||||
<!-- Help menu -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
|
||||
<event widget="MainWindow" command="key" arguments="16777216" />
|
||||
</events>
|
||||
6
Testing/nonmodal_manage_sets_test.xml
Normal file
6
Testing/nonmodal_manage_sets_test.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<events>
|
||||
<!-- Test non-modal dialog lifecycle: open and close Manage Sets (QMainWindow) -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="dbMenu" />
|
||||
<event widget="MainWindow/menubar/dbMenu" command="activate" arguments="&Manage sets..." />
|
||||
<event widget="MainWindow/manageSetsWindow" command="key" arguments="16777216" />
|
||||
</events>
|
||||
6
Testing/nonmodal_tip_test.xml
Normal file
6
Testing/nonmodal_tip_test.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<events>
|
||||
<!-- Test non-modal dialog lifecycle: open and close Tip of the Day -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
|
||||
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&Tip of the Day" />
|
||||
<event widget="MainWindow/tipOfTheDayDialog" command="key" arguments="16777216" />
|
||||
</events>
|
||||
6
Testing/nonmodal_view_log_test.xml
Normal file
6
Testing/nonmodal_view_log_test.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<events>
|
||||
<!-- Test non-modal dialog lifecycle: open and close ViewLog -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
|
||||
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="View &Debug Log" />
|
||||
<event widget="MainWindow/viewLogDialog" command="key" arguments="16777216" />
|
||||
</events>
|
||||
118
Testing/run_test.sh
Executable file
118
Testing/run_test.sh
Executable file
|
|
@ -0,0 +1,118 @@
|
|||
#!/bin/bash
|
||||
# Run QtTesting XML test files against the Cockatrice build.
|
||||
#
|
||||
# Usage:
|
||||
# ./Testing/run_test.sh [--visual] [--all | <test-file.xml> ...]
|
||||
#
|
||||
# Options:
|
||||
# --visual Run with native display (shows the GUI window).
|
||||
# Default is headless (QT_QPA_PLATFORM=offscreen).
|
||||
# --all Run every *.xml file in Testing/.
|
||||
#
|
||||
# Environment:
|
||||
# COCKATRICE_BIN Path to the client binary (default: build/debug/cockatrice/cockatrice,
|
||||
# or the .app bundle binary on macOS).
|
||||
# TEST_TIMEOUT Seconds a single test may run before it is killed (default: 180).
|
||||
# Uses timeout, or gtimeout from coreutils on macOS.
|
||||
#
|
||||
# Requires the debug build to exist at build/debug/cockatrice/cockatrice.
|
||||
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
if [ -n "${COCKATRICE_BIN:-}" ]; then
|
||||
COCKATRICE="$COCKATRICE_BIN"
|
||||
elif [ "$(uname -s)" = "Darwin" ]; then
|
||||
# A macOS build produces an app bundle, not a bare executable.
|
||||
COCKATRICE="$REPO_ROOT/build/debug/cockatrice/cockatrice.app/Contents/MacOS/cockatrice"
|
||||
else
|
||||
COCKATRICE="$REPO_ROOT/build/debug/cockatrice/cockatrice"
|
||||
fi
|
||||
TEST_DIR="$REPO_ROOT/Testing"
|
||||
TEST_TIMEOUT="${TEST_TIMEOUT:-180}"
|
||||
|
||||
# Stock macOS has no `timeout`; coreutils provides `gtimeout`. Without either the
|
||||
# runner still works, but a hung test is not killed.
|
||||
TIMEOUT=()
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
TIMEOUT=(timeout --foreground "$TEST_TIMEOUT")
|
||||
elif command -v gtimeout >/dev/null 2>&1; then
|
||||
TIMEOUT=(gtimeout --foreground "$TEST_TIMEOUT")
|
||||
else
|
||||
echo "Warning: neither timeout nor gtimeout found; tests are not time-limited." >&2
|
||||
fi
|
||||
|
||||
if [ ! -f "$COCKATRICE" ]; then
|
||||
echo "Error: $COCKATRICE not found. Build first:" >&2
|
||||
echo " cmake --build build/debug" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VISUAL=0
|
||||
if [ "${1:-}" = "--visual" ]; then
|
||||
VISUAL=1
|
||||
shift
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--all" ]; then
|
||||
shift
|
||||
TEST_FILES=("$TEST_DIR"/*.xml)
|
||||
if [ ${#TEST_FILES[@]} -eq 0 ]; then
|
||||
echo "No *.xml files found in $TEST_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [ $# -ge 1 ]; then
|
||||
TEST_FILES=("$@")
|
||||
else
|
||||
echo "Usage: $0 [--visual] [--all | <test-file.xml> ...]" >&2
|
||||
echo " Run a single test: $0 Testing/smoke_test.xml" >&2
|
||||
echo " Run all tests: $0 --all" >&2
|
||||
echo " Run visually: $0 --visual Testing/smoke_test.xml" >&2
|
||||
echo " Run all visually: $0 --visual --all" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
FAILED_TESTS=()
|
||||
|
||||
run_one() {
|
||||
local file="$1"
|
||||
|
||||
local -a cmd=()
|
||||
if [ "${#TIMEOUT[@]}" -gt 0 ]; then
|
||||
cmd=("${TIMEOUT[@]}")
|
||||
fi
|
||||
cmd+=("$COCKATRICE" --no-connect --test "$file")
|
||||
|
||||
if [ "$VISUAL" -eq 1 ]; then
|
||||
"${cmd[@]}"
|
||||
else
|
||||
env QT_QPA_PLATFORM=offscreen "${cmd[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
for f in "${TEST_FILES[@]}"; do
|
||||
name="$(basename "$f")"
|
||||
printf "%-40s " "$name"
|
||||
if run_one "$f"; then
|
||||
echo "PASS"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_TESTS+=("$name")
|
||||
fi
|
||||
done
|
||||
|
||||
TOTAL=$((PASS + FAIL))
|
||||
echo ""
|
||||
echo "Results: $PASS/$TOTAL passed, $FAIL failed"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "Failed:"
|
||||
for t in "${FAILED_TESTS[@]}"; do
|
||||
echo " $t"
|
||||
done
|
||||
exit 1
|
||||
fi
|
||||
6
Testing/settings_dialog_test.xml
Normal file
6
Testing/settings_dialog_test.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<events>
|
||||
<!-- Open Settings dialog via Cockatrice menu, then close with Escape -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
|
||||
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
|
||||
<event widget="MainWindow/settingsDialog" command="key" arguments="16777216" />
|
||||
</events>
|
||||
33
Testing/settings_navigation_test.xml
Normal file
33
Testing/settings_navigation_test.xml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<events>
|
||||
<!--
|
||||
Settings navigation (state assertions).
|
||||
|
||||
Navigates the settings page tree by label, checks that the page stack
|
||||
followed, then filters with the search box and verifies the text landed
|
||||
in the edit — then closes with the OK button.
|
||||
-->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
|
||||
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
|
||||
<event widget="MainWindow" command="pause" arguments="500" />
|
||||
|
||||
<!-- The dialog opens on the first page (General) -->
|
||||
<event widget="MainWindow/settingsDialog/settingsView/pagesContainer/pagesWidget" command="expectProperty" arguments="currentIndex,0" />
|
||||
|
||||
<!-- Navigate to Shortcuts, the last page of the tree -->
|
||||
<event widget="MainWindow/settingsDialog/settingsView/navTree" command="selectItem" arguments="Shortcuts" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
<event widget="MainWindow/settingsDialog/settingsView/pagesContainer/pagesWidget" command="expectProperty" arguments="currentIndex,7" />
|
||||
|
||||
<!-- ... and back to General -->
|
||||
<event widget="MainWindow/settingsDialog/settingsView/navTree" command="selectItem" arguments="General" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
<event widget="MainWindow/settingsDialog/settingsView/pagesContainer/pagesWidget" command="expectProperty" arguments="currentIndex,0" />
|
||||
|
||||
<!-- Filters the settings by search term -->
|
||||
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="typeText" arguments="sound" />
|
||||
<event widget="MainWindow" command="pause" arguments="300" />
|
||||
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="expectProperty" arguments="text,sound" />
|
||||
|
||||
<!-- Close -->
|
||||
<event widget="MainWindow/settingsDialog/settingsOkButton" command="activate" arguments="" />
|
||||
</events>
|
||||
13
Testing/settings_search_test.xml
Normal file
13
Testing/settings_search_test.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<events>
|
||||
<!-- Tier 5: State verification — open Settings, search for a page, close -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="cockatriceMenu" />
|
||||
<event widget="MainWindow/menubar/cockatriceMenu" command="activate" arguments="actionSettings" />
|
||||
<!-- Type "sound" in the settings search to filter pages -->
|
||||
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="115" />
|
||||
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="111" />
|
||||
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="117" />
|
||||
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="110" />
|
||||
<event widget="MainWindow/settingsDialog/settingsView/settingsSearchEdit" command="key" arguments="100" />
|
||||
<!-- Close settings -->
|
||||
<event widget="MainWindow/settingsDialog/settingsOkButton" command="activate" arguments="" />
|
||||
</events>
|
||||
8
Testing/smoke_test.xml
Normal file
8
Testing/smoke_test.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<events>
|
||||
<!-- Open Help menu by activating it on the menu bar -->
|
||||
<event widget="MainWindow/menubar" command="activate" arguments="helpMenu" />
|
||||
<!-- Click About Cockatrice (opens modal QMessageBox) -->
|
||||
<event widget="MainWindow/menubar/helpMenu" command="activate" arguments="&About Cockatrice" />
|
||||
<!-- Close the modal dialog by pressing Escape (Qt::Key_Escape = 16777216) -->
|
||||
<event widget="MainWindow/aboutDialog" command="key" arguments="16777216" />
|
||||
</events>
|
||||
|
|
@ -154,6 +154,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/palette_editor/palette_generator.cpp
|
||||
src/interface/palette_editor/quick_setup_panel.cpp
|
||||
src/interface/palette_editor/palette_grid_widget.cpp
|
||||
src/interface/palette_editor/font_grid_widget.cpp
|
||||
src/interface/palette_editor/palette_editor_dialog.cpp
|
||||
src/interface/widgets/cards/additional_info/color_identity_widget.cpp
|
||||
src/interface/widgets/cards/additional_info/deck_color_identity.cpp
|
||||
|
|
@ -449,6 +450,8 @@ set(cockatrice_SOURCES
|
|||
src/interface/intents/intent_open_shared_deck.h
|
||||
src/interface/intents/url_parser.cpp
|
||||
src/interface/intents/url_parser.h
|
||||
src/interface/widgets/server/user/user_games_model.cpp
|
||||
src/interface/widgets/server/user/user_games_model.h
|
||||
src/interface/widgets/server/user/user_info_popup.cpp
|
||||
src/interface/widgets/server/user/user_info_popup.h
|
||||
)
|
||||
|
|
@ -564,6 +567,14 @@ target_link_libraries(
|
|||
|
||||
cockatrice_target_enable_crash_reporting(cockatrice)
|
||||
|
||||
if(WITH_GUI_TEST)
|
||||
# These two translate between XML test files and the QtTesting event pipeline;
|
||||
# they need the QtTesting headers, so they only exist with the harness.
|
||||
target_sources(cockatrice PRIVATE src/game_graphics/pq_cockatrice_event_player.cpp src/interface/recording.cpp)
|
||||
target_link_libraries(cockatrice PRIVATE QtTesting)
|
||||
target_compile_definitions(cockatrice PRIVATE WITH_GUI_TEST)
|
||||
endif()
|
||||
|
||||
if(UNIX)
|
||||
if(APPLE)
|
||||
set(MACOSX_BUNDLE_INFO_STRING "${PROJECT_NAME}")
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
# or set <category> = false to disable logging
|
||||
|
||||
#main = true
|
||||
#recording = true
|
||||
#qt_translator = true
|
||||
#window_main.* = true
|
||||
#release_channel = true
|
||||
|
|
|
|||
42
cockatrice/src/client/network/interfaces/deck_analysis_url.h
Normal file
42
cockatrice/src/client/network/interfaces/deck_analysis_url.h
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* @file deck_analysis_url.h
|
||||
* @ingroup ApiInterfaces
|
||||
*/
|
||||
|
||||
#ifndef DECK_ANALYSIS_URL_H
|
||||
#define DECK_ANALYSIS_URL_H
|
||||
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
/**
|
||||
* Validates a deck address returned by a deck analysis service.
|
||||
*
|
||||
* Only the expected host (optionally "www."-prefixed), HTTPS, default ports and
|
||||
* URLs without user information are accepted. Legacy HTTP addresses for the same
|
||||
* service are upgraded to HTTPS in place instead of being rejected.
|
||||
*
|
||||
* Relative addresses must be resolved against the response URL by the caller.
|
||||
*
|
||||
* @return true when @p url names a deck on @p serviceHost; false otherwise.
|
||||
*/
|
||||
inline bool normalizeAnalysisUrl(QUrl &url, const QString &serviceHost)
|
||||
{
|
||||
const QString host = url.host().toLower();
|
||||
const bool legacyHttp = url.scheme() == QStringLiteral("http");
|
||||
const int defaultPort = legacyHttp ? 80 : 443;
|
||||
if (!url.isValid() || (!legacyHttp && url.scheme() != QStringLiteral("https")) || !url.userInfo().isEmpty() ||
|
||||
!url.path().startsWith('/') || (url.port() != -1 && url.port() != defaultPort) ||
|
||||
(host != serviceHost && host != QStringLiteral("www.") + serviceHost)) {
|
||||
return false;
|
||||
}
|
||||
// Both services accept HTTPS. Older public metadata may still identify the
|
||||
// same deck with HTTP; retain its host/path/query while using HTTPS to open it.
|
||||
if (legacyHttp) {
|
||||
url.setScheme(QStringLiteral("https"));
|
||||
url.setPort(-1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
#include "deck_stats_interface.h"
|
||||
|
||||
#include "deck_analysis_url.h"
|
||||
#include "libcockatrice/card/card_info.h"
|
||||
#include "libcockatrice/card/database/card_database_querier.h"
|
||||
#include "libcockatrice/deck_list/tree/inner_deck_list_node.h"
|
||||
|
|
@ -10,12 +11,14 @@
|
|||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QPointer>
|
||||
#include <QRegularExpression>
|
||||
#include <QRegularExpressionMatch>
|
||||
#include <QSharedPointer>
|
||||
#include <QString>
|
||||
#include <QTextDocumentFragment>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <QWidget>
|
||||
#include <functional>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
|
|
@ -29,30 +32,56 @@ DeckStatsInterface::DeckStatsInterface(QObject *parent) : QObject(parent)
|
|||
connect(manager, &QNetworkAccessManager::finished, this, &DeckStatsInterface::queryFinished);
|
||||
}
|
||||
|
||||
void DeckStatsInterface::showError(const QString &message)
|
||||
{
|
||||
// The notice belongs to the originating editor, not this one-shot request.
|
||||
// Retire the request before opening UI: opening a window may reenter Qt.
|
||||
QWidget *owner = qobject_cast<QWidget *>(parent());
|
||||
auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner);
|
||||
notice->setTextFormat(Qt::PlainText);
|
||||
notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard);
|
||||
notice->setAttribute(Qt::WA_DeleteOnClose);
|
||||
deleteLater();
|
||||
notice->open();
|
||||
}
|
||||
|
||||
void DeckStatsInterface::queryFinished(QNetworkReply *reply)
|
||||
{
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
QMessageBox::critical(nullptr, tr("Error"), reply->errorString());
|
||||
reply->deleteLater();
|
||||
deleteLater();
|
||||
if (replyHandled) {
|
||||
return;
|
||||
}
|
||||
|
||||
QString data(reply->readAll());
|
||||
replyHandled = true;
|
||||
// All terminal paths release the reply before showing UI or invoking an
|
||||
// external URL handler, either of which may destroy the originating editor.
|
||||
reply->deleteLater();
|
||||
|
||||
static const QRegularExpression rx("<meta property=\"og:url\" content=\"([^\"]+)\"");
|
||||
auto match = rx.match(data);
|
||||
if (!match.hasMatch()) {
|
||||
QMessageBox::critical(nullptr, tr("Error"), tr("The reply from the server could not be parsed."));
|
||||
deleteLater();
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
showError(reply->errorString());
|
||||
return;
|
||||
}
|
||||
|
||||
QString deckUrl = match.captured(1);
|
||||
QDesktopServices::openUrl(deckUrl);
|
||||
const QString data = QString::fromUtf8(reply->readAll());
|
||||
static const QRegularExpression rx(
|
||||
R"(<meta\b(?=[^>]*\bproperty\s*=\s*(['"])og:url\1)[^>]*\bcontent\s*=\s*(['"])(.*?)\2)",
|
||||
QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption);
|
||||
const auto match = rx.match(data);
|
||||
if (!match.hasMatch()) {
|
||||
showError(tr("The reply from Deckstats did not contain a deck address."));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString address = QTextDocumentFragment::fromHtml(match.captured(3)).toPlainText();
|
||||
QUrl deckUrl = reply->url().resolved(QUrl(address, QUrl::StrictMode));
|
||||
if (address.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("deckstats.net"))) {
|
||||
showError(tr("The reply from Deckstats contained an invalid deck address."));
|
||||
return;
|
||||
}
|
||||
|
||||
QPointer<DeckStatsInterface> guard(this);
|
||||
deleteLater();
|
||||
if (!QDesktopServices::openUrl(deckUrl) && guard) {
|
||||
showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1")
|
||||
.arg(deckUrl.toDisplayString()));
|
||||
}
|
||||
}
|
||||
|
||||
void DeckStatsInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data)
|
||||
|
|
@ -60,12 +89,8 @@ void DeckStatsInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray
|
|||
DeckList deckWithoutTokens;
|
||||
copyDeckWithoutTokens(deck, deckWithoutTokens);
|
||||
|
||||
QUrl params;
|
||||
QUrlQuery urlQuery;
|
||||
urlQuery.addQueryItem("deck", deckWithoutTokens.writeToString_Plain());
|
||||
urlQuery.addQueryItem("decktitle", deck.getName());
|
||||
params.setQuery(urlQuery);
|
||||
data.append(params.query(QUrl::EncodeReserved).toUtf8());
|
||||
data.append("deck=" + QUrl::toPercentEncoding(deckWithoutTokens.writeToString_Plain()));
|
||||
data.append("&decktitle=" + QUrl::toPercentEncoding(deck.getName()));
|
||||
}
|
||||
|
||||
void DeckStatsInterface::analyzeDeck(const DeckList &deck)
|
||||
|
|
@ -84,7 +109,9 @@ void DeckStatsInterface::copyDeckWithoutTokens(const DeckList &source, DeckList
|
|||
{
|
||||
auto copyIfNotAToken = [&destination](const auto node, const auto card) {
|
||||
CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName());
|
||||
if (dbCard && !dbCard->getIsToken() && node->getName() != DECK_ZONE_MAYBEBOARD) {
|
||||
// Missing local card data is not evidence that a card is a token. Keep
|
||||
// its original name and quantity so the service analyzes the whole deck.
|
||||
if ((!dbCard || !dbCard->getIsToken()) && node->getName() != DECK_ZONE_MAYBEBOARD) {
|
||||
DecklistCardNode *addedCard = destination.addCard(card->getName(), node->getName(), -1);
|
||||
addedCard->setNumber(card->getNumber());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
#include <qtmetamacros.h>
|
||||
|
||||
class QByteArray;
|
||||
class QString;
|
||||
class QNetworkAccessManager;
|
||||
class QNetworkReply;
|
||||
class DeckList;
|
||||
|
|
@ -20,6 +21,8 @@ class DeckStatsInterface : public QObject
|
|||
Q_OBJECT
|
||||
private:
|
||||
QNetworkAccessManager *manager;
|
||||
bool replyHandled = false;
|
||||
void showError(const QString &message);
|
||||
|
||||
/**
|
||||
* Deckstats doesn't recognize token cards, and instead tries to find the
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "tapped_out_interface.h"
|
||||
|
||||
#include "deck_analysis_url.h"
|
||||
#include "libcockatrice/card/card_info.h"
|
||||
#include "libcockatrice/card/database/card_database_querier.h"
|
||||
#include "libcockatrice/deck_list/tree/inner_deck_list_node.h"
|
||||
|
|
@ -13,15 +14,17 @@
|
|||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QPointer>
|
||||
#include <QRegularExpression>
|
||||
#include <QRegularExpressionMatch>
|
||||
#include <QRegularExpressionMatchIterator>
|
||||
#include <QSharedPointer>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QTextDocumentFragment>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <QVariant>
|
||||
#include <QWidget>
|
||||
#include <functional>
|
||||
#include <libcockatrice/card/database/card_database_manager.h>
|
||||
#include <libcockatrice/deck_list/deck_list.h>
|
||||
|
|
@ -35,54 +38,67 @@ TappedOutInterface::TappedOutInterface(QObject *parent) : QObject(parent)
|
|||
connect(manager, &QNetworkAccessManager::finished, this, &TappedOutInterface::queryFinished);
|
||||
}
|
||||
|
||||
void TappedOutInterface::showError(const QString &message)
|
||||
{
|
||||
// The notice belongs to the originating editor, not this one-shot request.
|
||||
// Retire the request before opening UI: opening a window may reenter Qt.
|
||||
QWidget *owner = qobject_cast<QWidget *>(parent());
|
||||
auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner);
|
||||
notice->setTextFormat(Qt::PlainText);
|
||||
notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard);
|
||||
notice->setAttribute(Qt::WA_DeleteOnClose);
|
||||
deleteLater();
|
||||
notice->open();
|
||||
}
|
||||
|
||||
void TappedOutInterface::queryFinished(QNetworkReply *reply)
|
||||
{
|
||||
if (replyHandled) {
|
||||
return;
|
||||
}
|
||||
replyHandled = true;
|
||||
// All terminal paths release the reply before showing UI or invoking an
|
||||
// external URL handler, either of which may destroy the originating editor.
|
||||
reply->deleteLater();
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
QMessageBox::critical(nullptr, tr("Error"), reply->errorString());
|
||||
reply->deleteLater();
|
||||
deleteLater();
|
||||
showError(reply->errorString());
|
||||
return;
|
||||
}
|
||||
|
||||
int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
if (reply->hasRawHeader("Location")) {
|
||||
/*
|
||||
* If the reply contains a "Location" header, a relative URL to the deck on TappedOut
|
||||
* can be extracted from the header. The http status is a 302 "redirect".
|
||||
*/
|
||||
QString deckUrl = reply->rawHeader("Location");
|
||||
qCInfo(TappedOutInterfaceLog) << "Tappedout: good reply, http status" << httpStatus << "location" << deckUrl;
|
||||
QDesktopServices::openUrl("https://tappedout.net" + deckUrl);
|
||||
} else {
|
||||
/*
|
||||
* Otherwise, the deck has not been parsed correctly. Error messages can be extracted
|
||||
* from the html. Css pseudo selector for errors: $("div.alert-danger > ul > li")
|
||||
*/
|
||||
QString data(reply->readAll());
|
||||
QStringList errorMessageList = {tr("Unable to analyze the deck.")};
|
||||
|
||||
static const QRegularExpression rx("<div class=\"alert alert-danger.*?<ul>(.*?)</ul>");
|
||||
auto match = rx.match(data);
|
||||
if (match.hasMatch()) {
|
||||
QString errors = match.captured(1);
|
||||
static const QRegularExpression rx2("<li>(.*?)</li>");
|
||||
static const QRegularExpression rxremove("<[^>]*>");
|
||||
auto matchIterator = rx2.globalMatch(errors);
|
||||
while (matchIterator.hasNext()) {
|
||||
auto match2 = matchIterator.next();
|
||||
errorMessageList.append(match2.captured(1).remove(rxremove).simplified());
|
||||
}
|
||||
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
if (httpStatus >= 200 && httpStatus < 400 && reply->hasRawHeader("Location")) {
|
||||
const QByteArray location = reply->rawHeader("Location");
|
||||
QUrl deckUrl = reply->url().resolved(QUrl::fromEncoded(location, QUrl::StrictMode));
|
||||
if (location.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("tappedout.net"))) {
|
||||
showError(tr("The reply from TappedOut contained an invalid deck address."));
|
||||
return;
|
||||
}
|
||||
|
||||
QString errorMessage = errorMessageList.join("\n");
|
||||
qCWarning(TappedOutInterfaceLog) << "Tappedout: bad reply, http status" << httpStatus << "size" << data.size()
|
||||
<< "message" << errorMessage;
|
||||
|
||||
QMessageBox::critical(nullptr, tr("Error"), errorMessage);
|
||||
QPointer<TappedOutInterface> guard(this);
|
||||
deleteLater();
|
||||
if (!QDesktopServices::openUrl(deckUrl) && guard) {
|
||||
showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1")
|
||||
.arg(deckUrl.toDisplayString()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
reply->deleteLater();
|
||||
deleteLater();
|
||||
const QString data = QString::fromUtf8(reply->readAll());
|
||||
QStringList errorMessageList = {tr("Unable to analyze the deck on TappedOut.")};
|
||||
static const QRegularExpression errorsRx("<div class=\"alert alert-danger.*?<ul>(.*?)</ul>",
|
||||
QRegularExpression::DotMatchesEverythingOption);
|
||||
const auto match = errorsRx.match(data);
|
||||
if (match.hasMatch()) {
|
||||
static const QRegularExpression itemRx("<li>(.*?)</li>", QRegularExpression::DotMatchesEverythingOption);
|
||||
auto items = itemRx.globalMatch(match.captured(1));
|
||||
while (items.hasNext()) {
|
||||
const QString error = QTextDocumentFragment::fromHtml(items.next().captured(1)).toPlainText().simplified();
|
||||
if (!error.isEmpty()) {
|
||||
errorMessageList.append(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
qCWarning(TappedOutInterfaceLog) << "TappedOut analysis failed, HTTP status" << httpStatus;
|
||||
showError(errorMessageList.join('\n'));
|
||||
}
|
||||
|
||||
void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data)
|
||||
|
|
@ -90,13 +106,9 @@ void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray
|
|||
DeckList mainboard, sideboard;
|
||||
copyDeckSplitMainAndSide(deck, mainboard, sideboard);
|
||||
|
||||
QUrl params;
|
||||
QUrlQuery urlQuery;
|
||||
urlQuery.addQueryItem("name", deck.getName());
|
||||
urlQuery.addQueryItem("mainboard", mainboard.writeToString_Plain(false, true));
|
||||
urlQuery.addQueryItem("sideboard", sideboard.writeToString_Plain(false, true));
|
||||
params.setQuery(urlQuery);
|
||||
data.append(params.query(QUrl::EncodeReserved).toUtf8());
|
||||
data.append("name=" + QUrl::toPercentEncoding(deck.getName()));
|
||||
data.append("&mainboard=" + QUrl::toPercentEncoding(mainboard.writeToString_Plain(false, true)));
|
||||
data.append("&sideboard=" + QUrl::toPercentEncoding(sideboard.writeToString_Plain(false, true)));
|
||||
}
|
||||
|
||||
void TappedOutInterface::analyzeDeck(const DeckList &deck)
|
||||
|
|
@ -117,7 +129,9 @@ void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckLi
|
|||
{
|
||||
auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) {
|
||||
CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName());
|
||||
if (!dbCard || dbCard->getIsToken() || node->getName() == DECK_ZONE_MAYBEBOARD) {
|
||||
// Exclude confirmed tokens, but preserve cards missing from the local
|
||||
// database, including newly released cards in an imported deck.
|
||||
if ((dbCard && dbCard->getIsToken()) || node->getName() == DECK_ZONE_MAYBEBOARD) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
inline Q_LOGGING_CATEGORY(TappedOutInterfaceLog, "tapped_out_interface");
|
||||
|
||||
class QByteArray;
|
||||
class QString;
|
||||
class QNetworkAccessManager;
|
||||
class QNetworkReply;
|
||||
class DeckList;
|
||||
|
|
@ -29,6 +30,8 @@ class TappedOutInterface : public QObject
|
|||
Q_OBJECT
|
||||
private:
|
||||
QNetworkAccessManager *manager;
|
||||
bool replyHandled = false;
|
||||
void showError(const QString &message);
|
||||
|
||||
void copyDeckSplitMainAndSide(const DeckList &source, DeckList &mainboard, DeckList &sideboard);
|
||||
private slots:
|
||||
|
|
|
|||
|
|
@ -137,6 +137,9 @@ SettingsCache::SettingsCache()
|
|||
// before that rename (idempotent, safe to run on every startup, portable builds included).
|
||||
SettingsMigration::migrateGameFiltersToCamelCase(settingsPath);
|
||||
|
||||
// Retire appearance/maxFontSize into per-theme CardText max (idempotent, safe every startup).
|
||||
SettingsMigration::migrateAppearanceMaxFontSizeToTheme(settingsPath);
|
||||
|
||||
shortcutsSettings = new ShortcutsSettings(settingsPath, this);
|
||||
cardDatabaseSettings = new CardDatabaseSettings(settingsPath, this);
|
||||
serversSettings = new ServersSettings(settingsPath, this);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "shortcut_treeview.h"
|
||||
|
||||
#include "../../interface/theme_manager.h"
|
||||
#include "cache_settings.h"
|
||||
#include "shortcuts_settings.h"
|
||||
|
||||
|
|
@ -78,6 +79,18 @@ ShortcutTreeView::ShortcutTreeView(QWidget *parent) : QTreeView(parent)
|
|||
|
||||
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
|
||||
&ShortcutTreeView::refreshShortcuts);
|
||||
|
||||
// Group headers store their font on the item; re-apply the role family on
|
||||
// font changes (setFont emits itemChanged, which repaints the rows).
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
|
||||
QFont groupFont = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
groupFont.setBold(true);
|
||||
for (int row = 0; row < shortcutsModel->rowCount(); ++row) {
|
||||
if (auto *groupItem = shortcutsModel->item(row)) {
|
||||
groupItem->setFont(groupFont);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void ShortcutTreeView::populateShortcutsModel()
|
||||
|
|
@ -93,7 +106,8 @@ void ShortcutTreeView::populateShortcutsModel()
|
|||
curParent = parentItems.value(group);
|
||||
} else {
|
||||
curParent = new QStandardItem(group);
|
||||
static QFont font = curParent->font();
|
||||
// Family comes from the InterfaceDefault theme role; bold stays caller-side.
|
||||
QFont font = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
font.setBold(true);
|
||||
curParent->setFont(font);
|
||||
parentItems.insert(group, curParent);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#include "filter_tree_model.h"
|
||||
|
||||
#include "../interface/theme_manager.h"
|
||||
|
||||
#include <QFlags>
|
||||
#include <QFont>
|
||||
#include <QString>
|
||||
|
|
@ -39,6 +41,12 @@ FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent)
|
|||
connect(fTree, &FilterTree::postInsertRow, this, &FilterTreeModel::proxyEndInsertRow);
|
||||
connect(fTree, &FilterTree::preRemoveRow, this, &FilterTreeModel::proxyBeginRemoveRow);
|
||||
connect(fTree, &FilterTree::postRemoveRow, this, &FilterTreeModel::proxyEndRemoveRow);
|
||||
// FontRole data reads the DeckList role live; rebroadcast so attached
|
||||
// views re-query instead of waiting for an incidental repaint.
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
|
||||
emit layoutAboutToBeChanged();
|
||||
emit layoutChanged();
|
||||
});
|
||||
}
|
||||
|
||||
FilterTreeModel::~FilterTreeModel()
|
||||
|
|
@ -202,7 +210,8 @@ QVariant FilterTreeModel::data(const QModelIndex &index, int role) const
|
|||
switch (role) {
|
||||
case Qt::FontRole:
|
||||
if (!node->isLeaf()) {
|
||||
QFont f;
|
||||
// Family comes from the DeckList theme role; bold stays caller-side.
|
||||
QFont f = themeManager->appFont(AppFont::DeckList);
|
||||
f.setBold(true);
|
||||
return f;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#include "../../client/settings/cache_settings.h"
|
||||
#include "../../interface/card_localization.h"
|
||||
#include "../../interface/card_picture_loader/card_picture_loader.h"
|
||||
#include "../../interface/theme_manager.h"
|
||||
#include "../card_dimensions.h"
|
||||
#include "../game_scene.h"
|
||||
#include "../z_values.h"
|
||||
|
|
@ -34,7 +35,6 @@
|
|||
#include <QVariant>
|
||||
#include <QtGlobal>
|
||||
#include <algorithm>
|
||||
#include <libcockatrice/settings/appearance_settings.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
#include <libcockatrice/settings/debug_settings.h>
|
||||
#include <qnamespace.h>
|
||||
|
|
@ -143,8 +143,8 @@ QSizeF AbstractCardItem::getTranslatedSize(QPainter *painter) const
|
|||
|
||||
void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle)
|
||||
{
|
||||
const int MAX_FONT_SIZE = SettingsCache::instance().appearance().getMaxFontSize();
|
||||
const int fontSize = std::max(9, MAX_FONT_SIZE);
|
||||
const int minSize = std::max(1, themeManager->cardTextMinSize());
|
||||
const int fontSize = std::max(minSize, std::max(1, themeManager->cardTextMaxSize()));
|
||||
|
||||
QRectF totalBoundingRect = painter->combinedTransform().mapRect(boundingRect());
|
||||
|
||||
|
|
@ -154,7 +154,7 @@ void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &transla
|
|||
painter->rotate(angle);
|
||||
painter->translate(-translatedSize.width() / 2, -translatedSize.height() / 2);
|
||||
|
||||
QFont f;
|
||||
QFont f = themeManager->appFont(AppFont::CardText);
|
||||
f.setPixelSize(fontSize * scale);
|
||||
|
||||
painter->setFont(f);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#include "abstract_graphics_item.h"
|
||||
|
||||
#include "../../interface/theme_manager.h"
|
||||
|
||||
#include <QBrush>
|
||||
#include <QFont>
|
||||
#include <QFontMetrics>
|
||||
|
|
@ -20,7 +22,7 @@ void AbstractGraphicsItem::paintNumberEllipse(int number,
|
|||
painter->save();
|
||||
|
||||
QString numStr = QString::number(number);
|
||||
QFont font("Serif");
|
||||
QFont font = themeManager->cardTextFont();
|
||||
font.setPixelSize(fontSize);
|
||||
font.setWeight(QFont::Bold);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#include "counter_general.h"
|
||||
|
||||
#include "../../interface/pixel_map_generator.h"
|
||||
#include "../../interface/theme_manager.h"
|
||||
#include "abstract_graphics_item.h"
|
||||
|
||||
#include <QFont>
|
||||
|
|
@ -41,7 +42,7 @@ void GeneralCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /
|
|||
painter->drawPixmap(QPoint(0, 0), pixmap);
|
||||
|
||||
if (value) {
|
||||
QFont f("Serif");
|
||||
QFont f = themeManager->cardTextFont();
|
||||
f.setPixelSize(qMax((int)(radius * scaleFactor), 10));
|
||||
f.setWeight(QFont::Bold);
|
||||
painter->setPen(Qt::black);
|
||||
|
|
|
|||
|
|
@ -240,8 +240,8 @@ void DeckViewCardContainer::paint(QPainter *painter, const QStyleOptionGraphicsI
|
|||
painter->drawLine(QPointF(0, separatorY), QPointF(width, separatorY));
|
||||
|
||||
painter->setPen(QColor(Qt::white));
|
||||
QFont f("Serif");
|
||||
f.setStyleHint(QFont::Serif);
|
||||
// Family comes from the DeckList theme role; pixel sizes/weights unchanged.
|
||||
QFont f = themeManager->deckListFont();
|
||||
f.setPixelSize(24);
|
||||
f.setWeight(QFont::Bold);
|
||||
painter->setFont(f);
|
||||
|
|
@ -292,8 +292,8 @@ QList<QPair<int, int>> DeckViewCardContainer::getRowsAndCols() const
|
|||
|
||||
int DeckViewCardContainer::getCardTypeTextWidth() const
|
||||
{
|
||||
QFont f("Serif");
|
||||
f.setStyleHint(QFont::Serif);
|
||||
// Family comes from the DeckList theme role; pixel size/weight unchanged.
|
||||
QFont f = themeManager->deckListFont();
|
||||
f.setPixelSize(16);
|
||||
f.setWeight(QFont::Bold);
|
||||
QFontMetrics fm(f);
|
||||
|
|
@ -366,6 +366,15 @@ void DeckViewCardContainer::setWidth(qreal _width)
|
|||
|
||||
DeckViewScene::DeckViewScene(QObject *parent) : QGraphicsScene(parent), locked(true), deck(0), optimalAspectRatio(1.0)
|
||||
{
|
||||
// Zone headers read the DeckList role at paint time and cache their
|
||||
// rendering, so touch every item to invalidate the caches and pick up the
|
||||
// new family on font changes (containers cannot connect this themselves:
|
||||
// QGraphicsItem is not a QObject).
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
|
||||
for (auto *item : items()) {
|
||||
item->update();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
DeckViewScene::~DeckViewScene()
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ void ToggleButton::setState(bool _state)
|
|||
DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
|
||||
: QWidget(nullptr), visualDeckStorageWidget(nullptr), parentGame(parent), playerId(_playerId)
|
||||
{
|
||||
setObjectName("deckViewContainer");
|
||||
|
||||
loadLocalButton = new QPushButton;
|
||||
loadRemoteButton = new QPushButton;
|
||||
loadFromClipboardButton = new QPushButton;
|
||||
|
|
@ -80,6 +82,14 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
|
|||
forceStartGameButton = new QPushButton;
|
||||
sideboardLockButton = new ToggleButton;
|
||||
|
||||
loadLocalButton->setObjectName("loadLocalButton");
|
||||
loadRemoteButton->setObjectName("loadRemoteButton");
|
||||
loadFromClipboardButton->setObjectName("loadFromClipboardButton");
|
||||
readyStartButton->setObjectName("readyStartButton");
|
||||
forceStartGameButton->setObjectName("forceStartGameButton");
|
||||
unloadDeckButton->setObjectName("unloadDeckButton");
|
||||
sideboardLockButton->setObjectName("sideboardLockButton");
|
||||
|
||||
connect(loadLocalButton, &QPushButton::clicked, this, &DeckViewContainer::loadLocalDeck);
|
||||
connect(loadRemoteButton, &QPushButton::clicked, this, &DeckViewContainer::loadRemoteDeck);
|
||||
connect(loadFromClipboardButton, &QPushButton::clicked, this, &DeckViewContainer::loadFromClipboard);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class TabGame;
|
|||
TabbedDeckViewContainer::TabbedDeckViewContainer(int _playerId, TabGame *parent)
|
||||
: QTabWidget(nullptr), playerId(_playerId), parentGame(parent)
|
||||
{
|
||||
setObjectName(QString("tabbedDeckView_%1").arg(_playerId));
|
||||
setTabsClosable(true);
|
||||
connect(this, &QTabWidget::tabCloseRequested, this, &TabbedDeckViewContainer::closeTab);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
#include "game_scene.h"
|
||||
|
||||
#include "../client/settings/cache_settings.h"
|
||||
#include "../interface/theme_manager.h"
|
||||
#include "animated_item.h"
|
||||
#include "board/abstract_card_item.h"
|
||||
#include "board/arrow_item.h"
|
||||
|
|
@ -64,6 +65,14 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
|
|||
addItem(phasesToolbar);
|
||||
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this,
|
||||
&GameScene::rearrange);
|
||||
// CardText items (P/T boxes, counters, player names) read the role at
|
||||
// paint time and cache their rendering, so touch every item to invalidate
|
||||
// the caches and pick up the new family on font changes.
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
|
||||
for (auto *item : items()) {
|
||||
item->update();
|
||||
}
|
||||
});
|
||||
|
||||
rearrange();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
|
||||
#include "../client/settings/cache_settings.h"
|
||||
#include "../client/settings/shortcuts_settings.h"
|
||||
#include "../interface/theme_manager.h"
|
||||
#include "game_scene.h"
|
||||
|
||||
#include <QAction>
|
||||
#include <QBrush>
|
||||
#include <QColor>
|
||||
#include <QEvent>
|
||||
#include <QFont>
|
||||
#include <QGraphicsScene>
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
|
|
@ -24,6 +26,21 @@
|
|||
#include <libcockatrice/utility/qt_utils.h>
|
||||
#include <qnamespace.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
QFont deckOverlayFont(int pixelSize, bool bold)
|
||||
{
|
||||
// The drag/total counters and the tally columns are in-game overlays, not
|
||||
// deck list text: keep the monospace family the stylesheets used to set so
|
||||
// digit widths stay stable as counts tick and `white-space: pre` lines up.
|
||||
QFont font(QStringLiteral("monospace"));
|
||||
font.setStyleHint(QFont::Monospace);
|
||||
font.setPixelSize(pixelSize);
|
||||
font.setBold(bold);
|
||||
return font;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// QRubberBand calls raise() in showEvent() and changeEvent() to stay on top of siblings.
|
||||
// This subclass disables that behavior so dragCountLabel can appear above it.
|
||||
class SelectionRubberBand : public QRubberBand
|
||||
|
|
@ -48,6 +65,7 @@ protected:
|
|||
|
||||
GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, parent), rubberBand(0)
|
||||
{
|
||||
setObjectName("gameView");
|
||||
setBackgroundBrush(QBrush(QColor(0, 0, 0)));
|
||||
setRenderHints(QPainter::TextAntialiasing | QPainter::Antialiasing);
|
||||
setViewportUpdateMode(BoundingRectViewportUpdate);
|
||||
|
|
@ -75,15 +93,14 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
|
|||
rubberBand = new SelectionRubberBand(QRubberBand::Rectangle, this);
|
||||
|
||||
const QString baseProperties = "color: white; "
|
||||
"font-family: monospace; "
|
||||
"background-color: rgba(0, 0, 0, 160); "
|
||||
"border-radius: 3px; "
|
||||
"padding: 1px 2px; "
|
||||
"white-space: pre;";
|
||||
|
||||
const QString dragCountLabelStyle = baseProperties + "font-size: 14px; font-weight: bold;";
|
||||
const QString totalCountLabelStyle = baseProperties + "font-size: 16px; font-weight: bold;";
|
||||
const QString subtypeTallyLabelStyle = baseProperties + "font-size: 12px;";
|
||||
const QString dragCountLabelStyle = baseProperties;
|
||||
const QString totalCountLabelStyle = baseProperties;
|
||||
const QString subtypeTallyLabelStyle = baseProperties;
|
||||
|
||||
dragCountLabel = new QLabel(this);
|
||||
dragCountLabel->setStyleSheet(dragCountLabelStyle);
|
||||
|
|
@ -100,6 +117,27 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
|
|||
tallyLayout->setContentsMargins(2, 2, 2, 2);
|
||||
tallyLayout->setSpacing(2);
|
||||
tallyContainer->hide();
|
||||
|
||||
applyDeckOverlayFonts();
|
||||
}
|
||||
|
||||
void GameView::applyDeckOverlayFonts()
|
||||
{
|
||||
dragCountLabel->setFont(deckOverlayFont(14, true));
|
||||
totalCountLabel->setFont(deckOverlayFont(16, true));
|
||||
tallyContainer->setFont(deckOverlayFont(12, false));
|
||||
for (int i = 0; i < tallyLayout->count(); ++i) {
|
||||
if (auto *label = qobject_cast<QLabel *>(tallyLayout->itemAt(i)->widget())) {
|
||||
// Count column (odd positions) keeps the bold emphasis.
|
||||
const bool count = i % 2 == 1;
|
||||
label->setFont(deckOverlayFont(count ? 14 : 12, count));
|
||||
}
|
||||
}
|
||||
|
||||
// The container keeps the size computed from the previous fonts, so force
|
||||
// rebuildTallyLabels() to re-measure it instead of reusing cachedTallyRows.
|
||||
cachedTallyRows.clear();
|
||||
updateTotalSelectionCount();
|
||||
}
|
||||
|
||||
void GameView::resizeEvent(QResizeEvent *event)
|
||||
|
|
@ -219,9 +257,8 @@ QSize GameView::rebuildTallyLabels(const QList<TallyRow> &entries)
|
|||
{
|
||||
clearTallyLabels();
|
||||
|
||||
const QString nameStyle = QStringLiteral("color: white; font-size: 12px; background: transparent;");
|
||||
const QString countStyle =
|
||||
QStringLiteral("color: white; font-size: 14px; font-weight: bold; background: transparent;");
|
||||
const QString nameStyle = QStringLiteral("color: white; background: transparent;");
|
||||
const QString countStyle = QStringLiteral("color: white; background: transparent;");
|
||||
|
||||
int totalHeight = 0;
|
||||
int maxNameWidth = 0;
|
||||
|
|
@ -231,11 +268,13 @@ QSize GameView::rebuildTallyLabels(const QList<TallyRow> &entries)
|
|||
for (const TallyRow &entry : entries) {
|
||||
auto *nameLabel = new QLabel(entry.name, tallyContainer);
|
||||
nameLabel->setStyleSheet(nameStyle);
|
||||
nameLabel->setFont(deckOverlayFont(12, false));
|
||||
nameLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
tallyLayout->addWidget(nameLabel, row, 0);
|
||||
|
||||
auto *countLabel = new QLabel(entry.value, tallyContainer);
|
||||
countLabel->setStyleSheet(countStyle);
|
||||
countLabel->setFont(deckOverlayFont(14, true));
|
||||
countLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||
tallyLayout->addWidget(countLabel, row, 1);
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ private slots:
|
|||
void refreshShortcuts();
|
||||
void updateTotalSelectionCount(const QSize &viewSize = QSize());
|
||||
void setFocusDisabled(bool disabled);
|
||||
void applyDeckOverlayFonts();
|
||||
public slots:
|
||||
void updateSceneRect(const QRectF &rect);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include "../../client/settings/cache_settings.h"
|
||||
#include "../../interface/pixel_map_generator.h"
|
||||
#include "../../interface/theme_manager.h"
|
||||
#include "../board/abstract_counter.h"
|
||||
#include "../board/abstract_graphics_item.h"
|
||||
#include "../board/arrow_target.h"
|
||||
|
|
@ -76,7 +77,7 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*
|
|||
|
||||
QRectF translatedRect = path.controlPointRect();
|
||||
QSize translatedSize = translatedRect.size().toSize();
|
||||
QFont font("Serif");
|
||||
QFont font = themeManager->cardTextFont();
|
||||
font.setWeight(QFont::Bold);
|
||||
font.setPixelSize(qMax(qRound(translatedSize.height() / 1.3), 9));
|
||||
painter->setFont(font);
|
||||
|
|
@ -226,7 +227,7 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o
|
|||
name = name.mid(0, 10) + "...";
|
||||
}
|
||||
|
||||
QFont font;
|
||||
QFont font = themeManager->appFont(AppFont::CardText);
|
||||
font.setPixelSize(qMax(qRound(translatedNameRect.height() / 1.5), 9));
|
||||
painter->setFont(font);
|
||||
painter->setPen(Qt::white);
|
||||
|
|
|
|||
628
cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp
Normal file
628
cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
#include "pq_cockatrice_event_player.h"
|
||||
|
||||
#include "../../interface/widgets/tabs/tab_game.h"
|
||||
#include "board/card_item.h"
|
||||
#include "game_scene.h"
|
||||
#include "game_view.h"
|
||||
#include "zones/card_zone.h"
|
||||
|
||||
#include <QAbstractItemModel>
|
||||
#include <QAbstractItemView>
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QGraphicsScene>
|
||||
#include <QGraphicsView>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMenu>
|
||||
#include <QMouseEvent>
|
||||
#include <QTreeWidget>
|
||||
#include <libcockatrice/game/abstract_game.h>
|
||||
#include <libcockatrice/game/game_state.h>
|
||||
#include <libcockatrice/game/player/player_actions.h>
|
||||
#include <libcockatrice/game/player/player_logic.h>
|
||||
#include <libcockatrice/game/player/player_manager.h>
|
||||
|
||||
Q_LOGGING_CATEGORY(PQ_COCKATRICE_EVENT_PLAYER, "cockatrice.guiTest.eventPlayer")
|
||||
|
||||
namespace
|
||||
{
|
||||
/**
|
||||
* Strips Qt's mnemonic markers so a test can name a menu entry by the label the
|
||||
* user sees ("Turn Over" rather than "T&urn Over").
|
||||
*/
|
||||
QString normalizeActionText(const QString &text)
|
||||
{
|
||||
QString normalized = text;
|
||||
normalized.remove('&');
|
||||
return normalized.simplified();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a printable character onto the key a US keyboard would report for it, so
|
||||
* typing produces the same QKeyEvents a recording would.
|
||||
*/
|
||||
Qt::Key keyForCharacter(QChar character)
|
||||
{
|
||||
if (character.isLetter()) {
|
||||
return static_cast<Qt::Key>(Qt::Key_A + (character.toUpper().unicode() - 'A'));
|
||||
}
|
||||
if (character.isDigit()) {
|
||||
return static_cast<Qt::Key>(Qt::Key_0 + (character.unicode() - '0'));
|
||||
}
|
||||
switch (character.unicode()) {
|
||||
case ' ':
|
||||
return Qt::Key_Space;
|
||||
case '\n':
|
||||
return Qt::Key_Return;
|
||||
case '\t':
|
||||
return Qt::Key_Tab;
|
||||
case '-':
|
||||
return Qt::Key_Minus;
|
||||
case '_':
|
||||
return Qt::Key_Underscore;
|
||||
case '/':
|
||||
return Qt::Key_Slash;
|
||||
case ',':
|
||||
return Qt::Key_Comma;
|
||||
case '.':
|
||||
return Qt::Key_Period;
|
||||
case '\'':
|
||||
return Qt::Key_Apostrophe;
|
||||
case ';':
|
||||
return Qt::Key_Semicolon;
|
||||
case ':':
|
||||
return Qt::Key_Colon;
|
||||
case '"':
|
||||
return Qt::Key_QuoteDbl;
|
||||
case '&':
|
||||
return Qt::Key_Ampersand;
|
||||
case '(':
|
||||
return Qt::Key_ParenLeft;
|
||||
case ')':
|
||||
return Qt::Key_ParenRight;
|
||||
case '[':
|
||||
return Qt::Key_BracketLeft;
|
||||
case ']':
|
||||
return Qt::Key_BracketRight;
|
||||
default:
|
||||
return Qt::Key_unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/** Depth-first search for the first model index whose label matches @p text. */
|
||||
QModelIndex findModelIndexByText(const QAbstractItemModel *model, const QString &text, const QModelIndex &parent = {})
|
||||
{
|
||||
for (int row = 0; row < model->rowCount(parent); ++row) {
|
||||
const QModelIndex index = model->index(row, 0, parent);
|
||||
if (index.data(Qt::DisplayRole).toString() == text) {
|
||||
return index;
|
||||
}
|
||||
const QModelIndex child = findModelIndexByText(model, text, index);
|
||||
if (child.isValid()) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool needsShift(QChar character)
|
||||
{
|
||||
if (character.isUpper()) {
|
||||
return true;
|
||||
}
|
||||
return QStringLiteral("_:\"&()").contains(character);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
pqCockatriceEventPlayer::pqCockatriceEventPlayer(QObject *parent) : pqWidgetEventPlayer(parent)
|
||||
{
|
||||
}
|
||||
|
||||
bool pqCockatriceEventPlayer::playEvent(QObject *object, const QString &command, const QString &arguments, bool &error)
|
||||
{
|
||||
// A known command that fails is a handled-but-failed event: QtTesting logs
|
||||
// "Unhandled event" when playEvent returns false, so report through `error`
|
||||
// (the contract playExpectProperty already follows) and still return true.
|
||||
const auto run = [&error](bool ok) {
|
||||
if (!ok) {
|
||||
error = true;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
if (command == "clickCard") {
|
||||
return run(playClickCard(object, arguments));
|
||||
} else if (command == "flipCard") {
|
||||
return run(playFlipCard(object, arguments));
|
||||
} else if (command == "cardAction") {
|
||||
return run(playCardAction(object, arguments));
|
||||
} else if (command == "moveCard") {
|
||||
return run(playMoveCard(object, arguments));
|
||||
} else if (command == "drawArrow") {
|
||||
return run(playDrawArrow(object, arguments));
|
||||
} else if (command == "createToken") {
|
||||
return run(playCreateToken(object, arguments));
|
||||
} else if (command == "selectItem") {
|
||||
return run(playSelectItem(object, arguments));
|
||||
} else if (command == "typeText") {
|
||||
return run(playTypeText(object, arguments));
|
||||
} else if (command == "expectProperty") {
|
||||
return playExpectProperty(object, arguments, error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
QGraphicsItem *
|
||||
pqCockatriceEventPlayer::findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName)
|
||||
{
|
||||
for (QGraphicsItem *gi : scene->items()) {
|
||||
if (gi->type() != typeCard) {
|
||||
continue;
|
||||
}
|
||||
auto *card = static_cast<CardItem *>(gi);
|
||||
if (!card->getZone() || card->getZone()->getName() != zoneName) {
|
||||
continue;
|
||||
}
|
||||
QString actualName = card->getName();
|
||||
if (actualName == cardName || actualName.endsWith(" " + cardName)) {
|
||||
return card;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
QGraphicsItem *pqCockatriceEventPlayer::findZoneInScene(QGraphicsScene *scene, const QString &zoneName)
|
||||
{
|
||||
for (QGraphicsItem *gi : scene->items()) {
|
||||
if (gi->type() != typeZone) {
|
||||
continue;
|
||||
}
|
||||
auto *zone = static_cast<CardZone *>(gi);
|
||||
if (zone->getLogic() && zone->getLogic()->getName() == zoneName) {
|
||||
return zone;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
QAction *pqCockatriceEventPlayer::findActionInMenu(QMenu *menu, const QString &actionText)
|
||||
{
|
||||
if (!menu || actionText.isEmpty()) {
|
||||
return nullptr;
|
||||
}
|
||||
for (QAction *action : menu->actions()) {
|
||||
if (action->isSeparator()) {
|
||||
continue;
|
||||
}
|
||||
if (normalizeActionText(action->text()).contains(actionText, Qt::CaseInsensitive)) {
|
||||
return action;
|
||||
}
|
||||
if (QMenu *subMenu = action->menu()) {
|
||||
if (QAction *found = findActionInMenu(subMenu, actionText)) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
QMenu *pqCockatriceEventPlayer::visiblePopupMenu()
|
||||
{
|
||||
for (QWidget *w : QApplication::topLevelWidgets()) {
|
||||
auto *menu = qobject_cast<QMenu *>(w);
|
||||
if (menu && menu->isVisible()) {
|
||||
return menu;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
QPoint pqCockatriceEventPlayer::viewportPoint(QGraphicsView *view, const QGraphicsItem *item)
|
||||
{
|
||||
return view->mapFromScene(item->sceneBoundingRect().center());
|
||||
}
|
||||
void pqCockatriceEventPlayer::sendMouseEvent(QGraphicsView *view,
|
||||
QEvent::Type type,
|
||||
const QPoint &pos,
|
||||
Qt::MouseButton button,
|
||||
Qt::MouseButtons buttons)
|
||||
{
|
||||
QMouseEvent event(type, pos, view->viewport()->mapToGlobal(pos), button, buttons, Qt::NoModifier);
|
||||
qApp->notify(view->viewport(), &event);
|
||||
}
|
||||
|
||||
bool pqCockatriceEventPlayer::playClickCard(QObject *object, const QString &arguments)
|
||||
{
|
||||
auto *gv = qobject_cast<QGraphicsView *>(object);
|
||||
if (!gv) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: target widget is not a QGraphicsView";
|
||||
return false;
|
||||
}
|
||||
|
||||
QStringList parts = arguments.split(',');
|
||||
if (parts.size() < 2) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: arguments must be 'cardName,zoneName', got:" << arguments;
|
||||
return false;
|
||||
}
|
||||
|
||||
QGraphicsScene *scene = gv->scene();
|
||||
if (!scene) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: no scene on the QGraphicsView";
|
||||
return false;
|
||||
}
|
||||
|
||||
auto *target = findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed());
|
||||
if (!target) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
|
||||
<< "clickCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
|
||||
return false;
|
||||
}
|
||||
|
||||
const QPoint pos = viewportPoint(gv, target);
|
||||
// A double click is the play gesture from hand: press/release, then the
|
||||
// double-click event and its release, exactly as a recording produces it.
|
||||
sendMouseEvent(gv, QEvent::MouseButtonPress, pos, Qt::LeftButton, Qt::LeftButton);
|
||||
sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton);
|
||||
sendMouseEvent(gv, QEvent::MouseButtonDblClick, pos, Qt::LeftButton, Qt::LeftButton);
|
||||
sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool pqCockatriceEventPlayer::playFlipCard(QObject *object, const QString &arguments)
|
||||
{
|
||||
auto *gv = qobject_cast<QGraphicsView *>(object);
|
||||
if (!gv) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: target widget is not a QGraphicsView";
|
||||
return false;
|
||||
}
|
||||
|
||||
QStringList parts = arguments.split(',');
|
||||
if (parts.size() < 2) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: arguments must be 'cardName,zoneName', got:" << arguments;
|
||||
return false;
|
||||
}
|
||||
|
||||
QGraphicsScene *scene = gv->scene();
|
||||
if (!scene) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: no scene";
|
||||
return false;
|
||||
}
|
||||
|
||||
auto *card = qgraphicsitem_cast<CardItem *>(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()));
|
||||
if (!card) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
|
||||
<< "flipCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
|
||||
return false;
|
||||
}
|
||||
|
||||
return invokeCardMenuAction("flipCard", gv, card, QStringLiteral("Turn Over"));
|
||||
}
|
||||
|
||||
bool pqCockatriceEventPlayer::playCardAction(QObject *object, const QString &arguments)
|
||||
{
|
||||
auto *gv = qobject_cast<QGraphicsView *>(object);
|
||||
if (!gv) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: target widget is not a QGraphicsView";
|
||||
return false;
|
||||
}
|
||||
|
||||
QStringList parts = arguments.split(',');
|
||||
if (parts.size() < 3) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
|
||||
<< "cardAction: arguments must be 'cardName,zoneName,actionText', got:" << arguments;
|
||||
return false;
|
||||
}
|
||||
|
||||
QGraphicsScene *scene = gv->scene();
|
||||
if (!scene) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: no scene";
|
||||
return false;
|
||||
}
|
||||
|
||||
auto *card = qgraphicsitem_cast<CardItem *>(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()));
|
||||
if (!card) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
|
||||
<< "cardAction: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
|
||||
return false;
|
||||
}
|
||||
|
||||
return invokeCardMenuAction("cardAction", gv, card, parts.mid(2).join(',').trimmed());
|
||||
}
|
||||
|
||||
bool pqCockatriceEventPlayer::invokeCardMenuAction(const char *command,
|
||||
QGraphicsView *view,
|
||||
CardItem *card,
|
||||
const QString &actionText)
|
||||
{
|
||||
const QPoint pos = viewportPoint(view, card);
|
||||
sendMouseEvent(view, QEvent::MouseButtonPress, pos, Qt::RightButton, Qt::RightButton);
|
||||
sendMouseEvent(view, QEvent::MouseButtonRelease, pos, Qt::RightButton, Qt::NoButton);
|
||||
qApp->processEvents();
|
||||
|
||||
QMenu *menu = visiblePopupMenu();
|
||||
if (!menu) {
|
||||
qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no popup menu appeared after right-click";
|
||||
return false;
|
||||
}
|
||||
|
||||
QAction *action = findActionInMenu(menu, actionText);
|
||||
if (!action) {
|
||||
qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no context menu action matching" << actionText;
|
||||
menu->close();
|
||||
return false;
|
||||
}
|
||||
|
||||
action->trigger();
|
||||
menu->close();
|
||||
qApp->processEvents();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool pqCockatriceEventPlayer::playMoveCard(QObject *object, const QString &arguments)
|
||||
{
|
||||
auto *gv = qobject_cast<QGraphicsView *>(object);
|
||||
if (!gv) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: target widget is not a QGraphicsView";
|
||||
return false;
|
||||
}
|
||||
|
||||
QStringList parts = arguments.split(',');
|
||||
if (parts.size() < 3) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
|
||||
<< "moveCard: arguments must be 'cardName,srcZoneName,dstZoneName', got:" << arguments;
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString cardName = parts[0].trimmed();
|
||||
const QString srcZoneName = parts[1].trimmed();
|
||||
const QString dstZoneName = parts[2].trimmed();
|
||||
|
||||
QGraphicsScene *scene = gv->scene();
|
||||
if (!scene) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: no scene";
|
||||
return false;
|
||||
}
|
||||
|
||||
auto *card = findCardInScene(scene, cardName, srcZoneName);
|
||||
if (!card) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: card" << cardName << "not found in zone" << srcZoneName;
|
||||
return false;
|
||||
}
|
||||
|
||||
auto *dstZone = findZoneInScene(scene, dstZoneName);
|
||||
if (!dstZone) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: destination zone" << dstZoneName << "not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QPoint srcPos = viewportPoint(gv, card);
|
||||
const QPoint dstPos = viewportPoint(gv, dstZone);
|
||||
|
||||
// A drag has to travel past Qt's start distance before the scene turns it
|
||||
// into a card drag item, otherwise the move is interpreted as a click.
|
||||
const int dragDistance = 2 * QApplication::startDragDistance() + 10;
|
||||
const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0);
|
||||
|
||||
sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::LeftButton, Qt::LeftButton);
|
||||
sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::LeftButton, Qt::LeftButton);
|
||||
qApp->processEvents();
|
||||
sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::LeftButton, Qt::LeftButton);
|
||||
qApp->processEvents();
|
||||
sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::LeftButton, Qt::NoButton);
|
||||
qApp->processEvents();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool pqCockatriceEventPlayer::playDrawArrow(QObject *object, const QString &arguments)
|
||||
{
|
||||
auto *gv = qobject_cast<QGraphicsView *>(object);
|
||||
if (!gv) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: target widget is not a QGraphicsView";
|
||||
return false;
|
||||
}
|
||||
|
||||
QStringList parts = arguments.split(',');
|
||||
if (parts.size() < 4) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
|
||||
<< "drawArrow: arguments must be 'srcCard,srcZone,dstCard,dstZone', got:" << arguments;
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString srcCardName = parts[0].trimmed();
|
||||
const QString srcZoneName = parts[1].trimmed();
|
||||
const QString dstCardName = parts[2].trimmed();
|
||||
const QString dstZoneName = parts[3].trimmed();
|
||||
|
||||
QGraphicsScene *scene = gv->scene();
|
||||
if (!scene) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: no scene";
|
||||
return false;
|
||||
}
|
||||
|
||||
auto *srcCard = findCardInScene(scene, srcCardName, srcZoneName);
|
||||
if (!srcCard) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
|
||||
<< "drawArrow: source card" << srcCardName << "not found in zone" << srcZoneName;
|
||||
return false;
|
||||
}
|
||||
|
||||
auto *dstCard = findCardInScene(scene, dstCardName, dstZoneName);
|
||||
if (!dstCard) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
|
||||
<< "drawArrow: dest card" << dstCardName << "not found in zone" << dstZoneName;
|
||||
return false;
|
||||
}
|
||||
|
||||
const QPoint srcPos = viewportPoint(gv, srcCard);
|
||||
const QPoint dstPos = viewportPoint(gv, dstCard);
|
||||
|
||||
const int dragDistance = 2 * QApplication::startDragDistance() + 10;
|
||||
const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0);
|
||||
|
||||
sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::RightButton, Qt::RightButton);
|
||||
sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::RightButton, Qt::RightButton);
|
||||
qApp->processEvents();
|
||||
sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::RightButton, Qt::RightButton);
|
||||
sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::RightButton, Qt::NoButton);
|
||||
qApp->processEvents();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool pqCockatriceEventPlayer::playCreateToken(QObject *object, const QString &arguments)
|
||||
{
|
||||
auto *gv = qobject_cast<QGraphicsView *>(object);
|
||||
if (!gv) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: target widget is not a QGraphicsView";
|
||||
return false;
|
||||
}
|
||||
|
||||
QStringList parts = arguments.split(',');
|
||||
if (parts.size() < 3) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: arguments must be 'name,color,pt', got:" << arguments;
|
||||
return false;
|
||||
}
|
||||
|
||||
QString tokenName = parts[0].trimmed();
|
||||
QString tokenColor = parts[1].trimmed();
|
||||
QString tokenPt = parts[2].trimmed();
|
||||
|
||||
TabGame *tabGame = nullptr;
|
||||
QWidget *w = gv;
|
||||
while (w) {
|
||||
tabGame = qobject_cast<TabGame *>(w);
|
||||
if (tabGame) {
|
||||
break;
|
||||
}
|
||||
w = w->parentWidget();
|
||||
}
|
||||
if (!tabGame) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: TabGame not found";
|
||||
return false;
|
||||
}
|
||||
|
||||
AbstractGame *game = tabGame->getGame();
|
||||
if (!game) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no game";
|
||||
return false;
|
||||
}
|
||||
|
||||
int activePlayerId = game->getGameState()->getActivePlayer();
|
||||
PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(activePlayerId);
|
||||
if (!player) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no active local player";
|
||||
return false;
|
||||
}
|
||||
|
||||
TokenInfo info;
|
||||
info.name = tokenName;
|
||||
info.color = tokenColor;
|
||||
info.pt = tokenPt;
|
||||
info.destroy = true;
|
||||
info.faceDown = false;
|
||||
|
||||
player->getPlayerActions()->actCreateToken(info);
|
||||
|
||||
for (int i = 0; i < 10; ++i) {
|
||||
qApp->processEvents();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool pqCockatriceEventPlayer::playSelectItem(QObject *object, const QString &arguments)
|
||||
{
|
||||
const QString text = arguments.trimmed();
|
||||
if (text.isEmpty()) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: needs the label of the item to select";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (auto *tree = qobject_cast<QTreeWidget *>(object)) {
|
||||
QTreeWidgetItemIterator item(tree);
|
||||
while (*item) {
|
||||
if ((*item)->text(0) == text) {
|
||||
tree->setCurrentItem(*item);
|
||||
qApp->processEvents();
|
||||
return true;
|
||||
}
|
||||
++item;
|
||||
}
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no tree item labelled" << text;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (auto *view = qobject_cast<QAbstractItemView *>(object)) {
|
||||
const QModelIndex index = findModelIndexByText(view->model(), text);
|
||||
if (!index.isValid()) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no item labelled" << text;
|
||||
return false;
|
||||
}
|
||||
view->setCurrentIndex(index);
|
||||
qApp->processEvents();
|
||||
return true;
|
||||
}
|
||||
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: target is neither a tree widget nor an item view";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool pqCockatriceEventPlayer::playTypeText(QObject *object, const QString &arguments)
|
||||
{
|
||||
auto *widget = qobject_cast<QWidget *>(object);
|
||||
if (!widget) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "typeText: target is not a QWidget";
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const QChar character : arguments) {
|
||||
const Qt::Key key = keyForCharacter(character);
|
||||
const Qt::KeyboardModifiers modifiers = needsShift(character) ? Qt::ShiftModifier : Qt::NoModifier;
|
||||
QKeyEvent press(QEvent::KeyPress, key, modifiers, QString(character));
|
||||
QKeyEvent release(QEvent::KeyRelease, key, modifiers, QString(character));
|
||||
qApp->notify(widget, &press);
|
||||
qApp->notify(widget, &release);
|
||||
}
|
||||
qApp->processEvents();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool pqCockatriceEventPlayer::playExpectProperty(QObject *object, const QString &arguments, bool &error)
|
||||
{
|
||||
if (!object) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty: no target object";
|
||||
error = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
QStringList parts = arguments.split(',');
|
||||
if (parts.size() < 2) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
|
||||
<< "expectProperty: arguments must be 'propertyName,expectedValue', got:" << arguments;
|
||||
error = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const QString propertyName = parts[0].trimmed();
|
||||
const QString expected = parts.mid(1).join(',');
|
||||
|
||||
const QVariant actual = object->property(propertyName.toUtf8().constData());
|
||||
if (!actual.isValid()) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
|
||||
<< "expectProperty:" << object->objectName() << "has no property" << propertyName;
|
||||
error = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (actual.toString() != expected) {
|
||||
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty:" << object->objectName() << propertyName << "is"
|
||||
<< actual.toString() << "but the test expected" << expected;
|
||||
error = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
91
cockatrice/src/game_graphics/pq_cockatrice_event_player.h
Normal file
91
cockatrice/src/game_graphics/pq_cockatrice_event_player.h
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#ifndef PQ_COCKATRICE_EVENT_PLAYER_H
|
||||
#define PQ_COCKATRICE_EVENT_PLAYER_H
|
||||
|
||||
#include "pqWidgetEventPlayer.h"
|
||||
|
||||
#include <QModelIndex>
|
||||
|
||||
class CardItem;
|
||||
class QAction;
|
||||
class QGraphicsItem;
|
||||
class QGraphicsScene;
|
||||
class QGraphicsView;
|
||||
class QMenu;
|
||||
|
||||
/**
|
||||
* Custom event player for Cockatrice game interactions.
|
||||
*
|
||||
* Card commands are resolution independent: the card is looked up in the
|
||||
* QGraphicsScene by name and zone, then mouse events are sent at the card's
|
||||
* current viewport position. That keeps recordings usable across screen sizes
|
||||
* and window geometries.
|
||||
*
|
||||
* Commands:
|
||||
* clickCard "cardName,zoneName"
|
||||
* left press/release + double click — plays a card from hand, taps a
|
||||
* permanent, opens the deck view entry, depending on the zone
|
||||
* flipCard "cardName,zoneName"
|
||||
* right-click → "Turn Over" (shorthand for cardAction)
|
||||
* cardAction "cardName,zoneName,actionText"
|
||||
* right-click the card and trigger the first context menu action whose
|
||||
* label contains actionText; submenus are searched as well, so
|
||||
* "Add counter (A)" works
|
||||
* moveCard "cardName,srcZoneName,dstZoneName"
|
||||
* left-drag the card onto the destination zone — plays a card, moves it
|
||||
* to the graveyard/exile, puts it back into hand, ...
|
||||
* drawArrow "srcCard,srcZone,dstCard,dstZone"
|
||||
* right-drag an arrow from one card to another
|
||||
* createToken "name,color,pt"
|
||||
* create a token through the active local player, as the table context
|
||||
* menu's "Create token" dialog would
|
||||
* selectItem "item text"
|
||||
* selects the first item of a tree/list widget whose label matches, for
|
||||
* navigating the settings navigation tree or similar label-driven views
|
||||
* typeText "some text"
|
||||
* types the text into the widget one key event at a time — use it for
|
||||
* deck lists and search boxes instead of one event per character
|
||||
* expectProperty "propertyName,expectedValue"
|
||||
* assertion: playback fails unless object->property(propertyName)
|
||||
* equals expectedValue — for example "count,2" on the tab widget
|
||||
*/
|
||||
class pqCockatriceEventPlayer : public pqWidgetEventPlayer
|
||||
{
|
||||
Q_OBJECT
|
||||
typedef pqWidgetEventPlayer Superclass;
|
||||
|
||||
public:
|
||||
explicit pqCockatriceEventPlayer(QObject *parent = nullptr);
|
||||
|
||||
using Superclass::playEvent;
|
||||
|
||||
bool playEvent(QObject *object, const QString &command, const QString &arguments, bool &error) override;
|
||||
|
||||
private:
|
||||
bool playClickCard(QObject *object, const QString &arguments);
|
||||
bool playFlipCard(QObject *object, const QString &arguments);
|
||||
bool playCardAction(QObject *object, const QString &arguments);
|
||||
bool playMoveCard(QObject *object, const QString &arguments);
|
||||
bool playDrawArrow(QObject *object, const QString &arguments);
|
||||
bool playCreateToken(QObject *object, const QString &arguments);
|
||||
bool playSelectItem(QObject *object, const QString &arguments);
|
||||
bool playTypeText(QObject *object, const QString &arguments);
|
||||
bool playExpectProperty(QObject *object, const QString &arguments, bool &error);
|
||||
|
||||
/** @brief Triggers the context menu entry @p actionText after right-clicking @p card. */
|
||||
bool invokeCardMenuAction(const char *command, QGraphicsView *view, CardItem *card, const QString &actionText);
|
||||
|
||||
static QGraphicsItem *findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName);
|
||||
static QGraphicsItem *findZoneInScene(QGraphicsScene *scene, const QString &zoneName);
|
||||
static QAction *findActionInMenu(QMenu *menu, const QString &actionText);
|
||||
static QMenu *visiblePopupMenu();
|
||||
|
||||
/** @brief Viewport position of the item's centre, as the mouse events have to be sent. */
|
||||
static QPoint viewportPoint(QGraphicsView *view, const QGraphicsItem *item);
|
||||
static void sendMouseEvent(QGraphicsView *view,
|
||||
QEvent::Type type,
|
||||
const QPoint &pos,
|
||||
Qt::MouseButton button,
|
||||
Qt::MouseButtons buttons);
|
||||
};
|
||||
|
||||
#endif // PQ_COCKATRICE_EVENT_PLAYER_H
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
#include "deck_loader.h"
|
||||
|
||||
#include "../theme_manager.h"
|
||||
#include "libcockatrice/card/card_info.h"
|
||||
#include "libcockatrice/card/database/card_database_querier.h"
|
||||
#include "libcockatrice/deck_list/tree/abstract_deck_list_card_node.h"
|
||||
|
|
@ -579,6 +580,8 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
|
|||
if (!cards.isEmpty()) {
|
||||
QTextBlockFormat blockFormat;
|
||||
QTextCharFormat charFormat;
|
||||
// Family comes from the DeckList theme role; sizes/weights unchanged.
|
||||
charFormat.setFontFamilies({themeManager->deckListFont().family()});
|
||||
charFormat.setFontPointSize(11);
|
||||
charFormat.setFontWeight(QFont::Bold);
|
||||
cursor->insertBlock(blockFormat, charFormat);
|
||||
|
|
@ -592,6 +595,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
|
|||
const AbstractDecklistCardNode *card = cards[i];
|
||||
|
||||
QTextCharFormat cellCharFormat;
|
||||
cellCharFormat.setFontFamilies({themeManager->deckListFont().family()});
|
||||
cellCharFormat.setFontPointSize(9);
|
||||
|
||||
QTextTableCell cell = table->cellAt(i, 0);
|
||||
|
|
@ -613,6 +617,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
|
|||
|
||||
QTextBlockFormat blockFormat;
|
||||
QTextCharFormat charFormat;
|
||||
charFormat.setFontFamilies({themeManager->deckListFont().family()});
|
||||
charFormat.setFontPointSize(14);
|
||||
charFormat.setFontWeight(QFont::Bold);
|
||||
|
||||
|
|
@ -640,14 +645,18 @@ void DeckLoader::printDeckList(QPrinter *printer, const DeckList &deckList)
|
|||
{
|
||||
QTextDocument doc;
|
||||
|
||||
QFont font("Serif");
|
||||
font.setStyleHint(QFont::Serif);
|
||||
// Family comes from the DeckList theme role; the print sizes/weights below stay unchanged.
|
||||
// Print output is a one-shot snapshot and intentionally does not subscribe
|
||||
// to fontsChanged (there is no live view to repaint); reprinting picks up
|
||||
// the current role font.
|
||||
QFont font = themeManager->deckListFont();
|
||||
doc.setDefaultFont(font);
|
||||
|
||||
QTextCursor cursor(&doc);
|
||||
|
||||
QTextBlockFormat headerBlockFormat;
|
||||
QTextCharFormat headerCharFormat;
|
||||
headerCharFormat.setFontFamilies({themeManager->deckListFont().family()});
|
||||
headerCharFormat.setFontPointSize(16);
|
||||
headerCharFormat.setFontWeight(QFont::Bold);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#include "color_button.h"
|
||||
|
||||
#include "../theme_manager.h"
|
||||
|
||||
#include <QBrush>
|
||||
#include <QColorDialog>
|
||||
#include <QFont>
|
||||
|
|
@ -21,6 +23,7 @@ ColorButton::ColorButton(QWidget *parent) : QToolButton(parent)
|
|||
setCursor(Qt::PointingHandCursor);
|
||||
setToolTip(tr("Click to pick a color"));
|
||||
connect(this, &QToolButton::clicked, this, &ColorButton::pickColor);
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, &ColorButton::updateSwatch);
|
||||
}
|
||||
|
||||
void ColorButton::setColor(const QColor &c)
|
||||
|
|
@ -73,7 +76,8 @@ void ColorButton::updateSwatch()
|
|||
// Hex label — black or white for contrast
|
||||
int luma = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue();
|
||||
painter.setPen((luma > 128 && color.alpha() > 80) ? QColor(0, 0, 0, 180) : QColor(255, 255, 255, 200));
|
||||
QFont f = font();
|
||||
// Family comes from the InterfaceDefault theme role; the 8px size stays caller-side.
|
||||
QFont f = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
f.setPixelSize(8);
|
||||
painter.setFont(f);
|
||||
painter.drawText(rect(), Qt::AlignCenter, color.name().toUpper());
|
||||
|
|
|
|||
208
cockatrice/src/interface/palette_editor/font_grid_widget.cpp
Normal file
208
cockatrice/src/interface/palette_editor/font_grid_widget.cpp
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
#include "font_grid_widget.h"
|
||||
|
||||
#include "../theme_manager.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QFont>
|
||||
#include <QFontComboBox>
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
#include <QMetaEnum>
|
||||
#include <QSpinBox>
|
||||
#include <QtGlobal>
|
||||
|
||||
static constexpr int MIN_FONT_SIZE = 6;
|
||||
static constexpr int MAX_FONT_SIZE = 72;
|
||||
static constexpr int CARD_TEXT_MIN_SIZE = 1;
|
||||
static constexpr int CARD_TEXT_MAX_SIZE = 12;
|
||||
static constexpr int FALLBACK_FONT_SIZE = 10;
|
||||
|
||||
static const QMap<AppFont::Role, const char *> APP_FONT_DESCRIPTIONS = {
|
||||
{AppFont::InterfaceDefault, QT_TR_NOOP("Default font for menus, dialogs and widgets")},
|
||||
{AppFont::ChatLog, QT_TR_NOOP("Font for chat messages and logs")},
|
||||
{AppFont::CardText, QT_TR_NOOP("Font for text rendered on cards")},
|
||||
{AppFont::DeckList, QT_TR_NOOP("Font for deck lists and the deck editor")},
|
||||
};
|
||||
|
||||
static QString roleDisplayName(AppFont::Role role)
|
||||
{
|
||||
switch (role) {
|
||||
case AppFont::InterfaceDefault:
|
||||
return FontGridWidget::tr("Interface default");
|
||||
case AppFont::ChatLog:
|
||||
return FontGridWidget::tr("Chat log");
|
||||
case AppFont::CardText:
|
||||
return FontGridWidget::tr("Card text");
|
||||
case AppFont::DeckList:
|
||||
return FontGridWidget::tr("Deck list");
|
||||
default:
|
||||
return QString();
|
||||
}
|
||||
}
|
||||
|
||||
FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent)
|
||||
{
|
||||
auto *grid = new QGridLayout(this);
|
||||
grid->setSpacing(3);
|
||||
grid->setContentsMargins(12, 8, 12, 8);
|
||||
grid->setColumnStretch(0, 0);
|
||||
grid->setColumnStretch(1, 1);
|
||||
grid->setColumnStretch(2, 0);
|
||||
grid->setColumnStretch(3, 0);
|
||||
|
||||
QMetaEnum fontEnum = QMetaEnum::fromType<AppFont::Role>();
|
||||
for (int i = 0; i < fontEnum.keyCount(); ++i) {
|
||||
auto role = static_cast<AppFont::Role>(fontEnum.value(i));
|
||||
|
||||
auto *label = new QLabel(this);
|
||||
grid->addWidget(label, i, 0);
|
||||
|
||||
auto *familyCombo = new QFontComboBox(this);
|
||||
grid->addWidget(familyCombo, i, 1);
|
||||
connect(familyCombo, &QFontComboBox::currentFontChanged, this, [this] { emit fontsChanged(); });
|
||||
|
||||
if (role == AppFont::CardText) {
|
||||
auto *minSpin = new QSpinBox(this);
|
||||
minSpin->setRange(CARD_TEXT_MIN_SIZE, MAX_FONT_SIZE);
|
||||
grid->addWidget(minSpin, i, 2);
|
||||
connect(minSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); });
|
||||
|
||||
auto *maxSpin = new QSpinBox(this);
|
||||
maxSpin->setRange(MIN_FONT_SIZE, MAX_FONT_SIZE);
|
||||
grid->addWidget(maxSpin, i, 3);
|
||||
connect(maxSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); });
|
||||
|
||||
// Keep the range consistent: otherwise min 20 / max 12 saves as-is
|
||||
// and cardTextMaxSize() silently clamps the max up to the min, so
|
||||
// the max spin shows a size that is never used. Moving one spin
|
||||
// pulls the other's bound along.
|
||||
connect(minSpin, &QSpinBox::valueChanged, this, [maxSpin](int value) { maxSpin->setMinimum(value); });
|
||||
connect(maxSpin, &QSpinBox::valueChanged, this, [minSpin](int value) { minSpin->setMaximum(value); });
|
||||
|
||||
label->setBuddy(familyCombo);
|
||||
|
||||
fontRows[role] = {label, familyCombo, maxSpin, minSpin};
|
||||
continue;
|
||||
}
|
||||
|
||||
auto *sizeSpin = new QSpinBox(this);
|
||||
sizeSpin->setRange(MIN_FONT_SIZE, MAX_FONT_SIZE);
|
||||
grid->addWidget(sizeSpin, i, 2);
|
||||
connect(sizeSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); });
|
||||
|
||||
label->setBuddy(familyCombo);
|
||||
|
||||
fontRows[role] = {label, familyCombo, sizeSpin, nullptr};
|
||||
}
|
||||
|
||||
retranslateUi();
|
||||
}
|
||||
|
||||
void FontGridWidget::loadFonts(const FontConfig &cfg)
|
||||
{
|
||||
// Suppress fontsChanged while materializing fallback rows so the initial
|
||||
// load never reads as a user edit (mirrors paletteGrid blocking).
|
||||
blockSignals(true);
|
||||
QMetaEnum fontEnum = QMetaEnum::fromType<AppFont::Role>();
|
||||
for (int i = 0; i < fontEnum.keyCount(); ++i) {
|
||||
auto role = static_cast<AppFont::Role>(fontEnum.value(i));
|
||||
const FontRow &row = fontRows[role];
|
||||
|
||||
// Roles absent from the file resolve the same way the runtime does: an unset
|
||||
// role falls back to the config's InterfaceDefault, and only then to the
|
||||
// startup font. appFont() would return the live preview instead, so Reset and
|
||||
// a first load must not measure against it.
|
||||
const QFont fallback = cfg.fonts.value(AppFont::InterfaceDefault, themeManager->defaultAppFont(role));
|
||||
const QFont font = cfg.fonts.value(role, fallback);
|
||||
row.familyCombo->setCurrentFont(font);
|
||||
|
||||
if (role == AppFont::CardText && row.minSpin != nullptr) {
|
||||
// A previous load may have narrowed the linked bounds (min 20 pulls
|
||||
// max up to 20), which would clamp the values written below. Reset
|
||||
// both to the full range first; the valueChanged lambdas re-link
|
||||
// them as the values are set.
|
||||
row.minSpin->setRange(CARD_TEXT_MIN_SIZE, MAX_FONT_SIZE);
|
||||
row.sizeSpin->setRange(MIN_FONT_SIZE, MAX_FONT_SIZE);
|
||||
|
||||
// The max spin is a size-only override. Without CardTextMaxSize it
|
||||
// follows the CardText family size, and without CardText at all it
|
||||
// must match cardTextMaxSize()'s default rather than the system
|
||||
// startup size, which currentFontConfig() would otherwise bake in.
|
||||
const int fallbackMax = cfg.fonts.contains(role) ? font.pointSize() : CARD_TEXT_MAX_SIZE;
|
||||
int maxSize = cfg.cardTextMaxSize > 0 ? cfg.cardTextMaxSize : fallbackMax;
|
||||
if (maxSize <= 0) {
|
||||
maxSize = CARD_TEXT_MAX_SIZE;
|
||||
}
|
||||
int minSize = cfg.cardTextMinSize > 0 ? cfg.cardTextMinSize : themeManager->cardTextMinSize();
|
||||
minSize = qBound(CARD_TEXT_MIN_SIZE, minSize, MAX_FONT_SIZE);
|
||||
maxSize = qBound(MIN_FONT_SIZE, maxSize, MAX_FONT_SIZE);
|
||||
if (maxSize < minSize) {
|
||||
maxSize = minSize;
|
||||
}
|
||||
|
||||
// Max first, then min: each value re-applies the linked bound, so
|
||||
// the pair ends up consistent instead of clamping the smaller one.
|
||||
row.sizeSpin->setValue(maxSize);
|
||||
row.minSpin->setValue(minSize);
|
||||
continue;
|
||||
}
|
||||
|
||||
int size = font.pointSize();
|
||||
if (size <= 0) {
|
||||
size = qApp->font().pointSize();
|
||||
}
|
||||
if (size <= 0) {
|
||||
size = FALLBACK_FONT_SIZE;
|
||||
}
|
||||
row.sizeSpin->setValue(qBound(MIN_FONT_SIZE, size, MAX_FONT_SIZE));
|
||||
}
|
||||
blockSignals(false);
|
||||
}
|
||||
|
||||
FontConfig FontGridWidget::currentFontConfig() const
|
||||
{
|
||||
FontConfig cfg;
|
||||
QMetaEnum fontEnum = QMetaEnum::fromType<AppFont::Role>();
|
||||
for (int i = 0; i < fontEnum.keyCount(); ++i) {
|
||||
auto role = static_cast<AppFont::Role>(fontEnum.value(i));
|
||||
const FontRow &row = fontRows.value(role);
|
||||
|
||||
// Canonical family + point size only: a fresh QFont carries default
|
||||
// (non-bold, non-italic) weight/style, so nothing combo-side leaks in.
|
||||
QFont font(row.familyCombo->currentFont().family());
|
||||
font.setPointSize(row.sizeSpin->value());
|
||||
cfg.fonts[role] = font;
|
||||
|
||||
if (role == AppFont::CardText && row.minSpin != nullptr) {
|
||||
cfg.cardTextMinSize = row.minSpin->value();
|
||||
cfg.cardTextMaxSize = row.sizeSpin->value();
|
||||
}
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
void FontGridWidget::retranslateUi()
|
||||
{
|
||||
QMetaEnum fontEnum = QMetaEnum::fromType<AppFont::Role>();
|
||||
for (int i = 0; i < fontEnum.keyCount(); ++i) {
|
||||
auto role = static_cast<AppFont::Role>(fontEnum.value(i));
|
||||
const FontRow &row = fontRows.value(role);
|
||||
|
||||
row.label->setText(roleDisplayName(role));
|
||||
row.label->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, "")));
|
||||
row.familyCombo->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, "")));
|
||||
row.familyCombo->setAccessibleName(roleDisplayName(role));
|
||||
if (role == AppFont::CardText && row.minSpin != nullptr) {
|
||||
row.minSpin->setToolTip(tr("Minimum card text size"));
|
||||
row.minSpin->setAccessibleName(tr("Minimum card text size"));
|
||||
row.minSpin->setSuffix(tr(" pt"));
|
||||
row.sizeSpin->setToolTip(tr("Maximum card text size"));
|
||||
row.sizeSpin->setAccessibleName(tr("Maximum card text size"));
|
||||
row.sizeSpin->setSuffix(tr(" pt"));
|
||||
continue;
|
||||
}
|
||||
row.sizeSpin->setToolTip(tr("Point size for %1").arg(roleDisplayName(role)));
|
||||
row.sizeSpin->setAccessibleName(tr("Point size for %1").arg(roleDisplayName(role)));
|
||||
row.sizeSpin->setSuffix(tr(" pt"));
|
||||
}
|
||||
}
|
||||
44
cockatrice/src/interface/palette_editor/font_grid_widget.h
Normal file
44
cockatrice/src/interface/palette_editor/font_grid_widget.h
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#ifndef COCKATRICE_FONT_GRID_WIDGET_H
|
||||
#define COCKATRICE_FONT_GRID_WIDGET_H
|
||||
|
||||
#include "../theme_config.h"
|
||||
|
||||
#include <QMap>
|
||||
#include <QWidget>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
class QFontComboBox;
|
||||
class QGridLayout;
|
||||
class QLabel;
|
||||
class QSpinBox;
|
||||
|
||||
// Per-role font editors — one (family, point size) row per AppFont::Role.
|
||||
// Owns the load/read round-trip for FontConfig but has no file I/O, mirroring
|
||||
// PaletteGridWidget. Only family and point size are edited; weight and italic
|
||||
// stay caller-side concerns and are stripped when reading back.
|
||||
class FontGridWidget : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit FontGridWidget(QWidget *parent = nullptr);
|
||||
|
||||
void loadFonts(const FontConfig &cfg);
|
||||
FontConfig currentFontConfig() const;
|
||||
void retranslateUi();
|
||||
|
||||
signals:
|
||||
void fontsChanged();
|
||||
|
||||
private:
|
||||
struct FontRow
|
||||
{
|
||||
QLabel *label = nullptr;
|
||||
QFontComboBox *familyCombo = nullptr;
|
||||
QSpinBox *sizeSpin = nullptr;
|
||||
QSpinBox *minSpin = nullptr; // CardText only: shrink-to-fit floor
|
||||
};
|
||||
|
||||
QMap<AppFont::Role, FontRow> fontRows;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_FONT_GRID_WIDGET_H
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include "../theme_config.h"
|
||||
#include "../theme_manager.h"
|
||||
#include "font_grid_widget.h"
|
||||
#include "palette_generator.h"
|
||||
#include "palette_grid_widget.h"
|
||||
#include "quick_setup_panel.h"
|
||||
|
|
@ -41,6 +42,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt
|
|||
|
||||
// Load both scheme configs upfront so switching is instant
|
||||
loadSchemes();
|
||||
loadFonts();
|
||||
|
||||
loadedScheme = themeManager->isDarkMode(themeDirPath) ? "Dark" : "Light";
|
||||
|
||||
|
|
@ -125,6 +127,33 @@ void PaletteEditorDialog::setupUi()
|
|||
paletteGrid->setVisible(false);
|
||||
root->addWidget(paletteGrid, 1);
|
||||
|
||||
// Font section — sibling to the palette grid, sharing its collapsible
|
||||
// advanced area pattern. Fonts are shared per theme, not split by scheme.
|
||||
fontToggleButton = new QPushButton(this);
|
||||
fontToggleButton->setCheckable(true);
|
||||
fontToggleButton->setChecked(false);
|
||||
fontToggleButton->setFlat(true);
|
||||
fontToggleButton->setStyleSheet("QPushButton { text-align: left; padding: 5px 12px; font-weight: bold; }"
|
||||
"QPushButton:checked { }");
|
||||
root->addWidget(fontToggleButton);
|
||||
|
||||
fontSeparator = makeSeparator();
|
||||
fontSeparator->setVisible(false);
|
||||
root->addWidget(fontSeparator);
|
||||
|
||||
fontSection = new QWidget;
|
||||
auto *fontSectionLayout = new QVBoxLayout(fontSection);
|
||||
fontSectionLayout->setContentsMargins(0, 0, 0, 0);
|
||||
fontSectionLayout->setSpacing(0);
|
||||
fontNoteLabel = new QLabel(fontSection);
|
||||
fontNoteLabel->setContentsMargins(12, 4, 12, 0);
|
||||
fontNoteLabel->setWordWrap(true);
|
||||
fontSectionLayout->addWidget(fontNoteLabel);
|
||||
fontGrid = new FontGridWidget(fontSection);
|
||||
fontSectionLayout->addWidget(fontGrid);
|
||||
fontSection->setVisible(false);
|
||||
root->addWidget(fontSection);
|
||||
|
||||
// Footer
|
||||
root->addWidget(makeSeparator());
|
||||
footer = new QWidget;
|
||||
|
|
@ -157,6 +186,10 @@ void PaletteEditorDialog::setupUi()
|
|||
connect(autoApplyTimer, &QTimer::timeout, this, &PaletteEditorDialog::onApply);
|
||||
connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &PaletteEditorDialog::onGenerateFromAccent);
|
||||
connect(paletteGrid, &PaletteGridWidget::paletteChanged, this, [this] { autoApplyTimer->start(); });
|
||||
connect(fontGrid, &FontGridWidget::fontsChanged, this, [this] {
|
||||
fontsDirty = true;
|
||||
autoApplyTimer->start();
|
||||
});
|
||||
connect(revertButton, &QPushButton::clicked, this, &PaletteEditorDialog::onRevertToDefault);
|
||||
connect(resetBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onReset);
|
||||
connect(saveBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onSave);
|
||||
|
|
@ -166,15 +199,32 @@ void PaletteEditorDialog::setupUi()
|
|||
paletteGridToggleButton->setText(open ? tr("▼ Edit Palette") : tr("▶ Edit Palette"));
|
||||
paletteGridSeparator->setVisible(open);
|
||||
paletteGrid->setVisible(open);
|
||||
|
||||
if (open) {
|
||||
setMinimumHeight(680);
|
||||
resize(width(), 680);
|
||||
} else {
|
||||
setMinimumHeight(220);
|
||||
adjustSize(); // shrinks to fit just the visible content
|
||||
}
|
||||
updateAdvancedHeight();
|
||||
});
|
||||
connect(fontToggleButton, &QPushButton::toggled, this, [this](bool open) {
|
||||
fontToggleButton->setText(open ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts"));
|
||||
fontSeparator->setVisible(open);
|
||||
fontSection->setVisible(open);
|
||||
updateAdvancedHeight();
|
||||
});
|
||||
}
|
||||
|
||||
void PaletteEditorDialog::updateAdvancedHeight()
|
||||
{
|
||||
int height = 220;
|
||||
if (paletteGridToggleButton->isChecked()) {
|
||||
height += 460;
|
||||
}
|
||||
if (fontToggleButton->isChecked()) {
|
||||
height += 200;
|
||||
}
|
||||
setMinimumHeight(height);
|
||||
|
||||
if (paletteGrid->isVisible() || fontSection->isVisible()) {
|
||||
resize(width(), height);
|
||||
} else {
|
||||
adjustSize(); // shrinks to fit just the visible content
|
||||
}
|
||||
}
|
||||
|
||||
void PaletteEditorDialog::retranslateUi()
|
||||
|
|
@ -189,12 +239,16 @@ void PaletteEditorDialog::retranslateUi()
|
|||
editingLabel->setText(tr("Editing:"));
|
||||
paletteGridToggleButton->setText(tr("▶ Edit Palette"));
|
||||
paletteGridToggleButton->setToolTip(tr("Show or hide the per-role colour grid for manual tweaks"));
|
||||
fontToggleButton->setText(fontSection->isVisible() ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts"));
|
||||
fontToggleButton->setToolTip(tr("Show or hide the per-purpose font settings for this theme"));
|
||||
fontNoteLabel->setText(tr("Fonts belong to theme %1 (shared by Light and Dark schemes)").arg(themeName));
|
||||
fontGrid->retranslateUi();
|
||||
revertButton->setText(tr("↺ Revert to theme default"));
|
||||
|
||||
resetBtn->setText(tr("Reset"));
|
||||
saveBtn->setText(tr("Save"));
|
||||
resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette"));
|
||||
saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower()));
|
||||
resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette and fonts"));
|
||||
saveBtn->setToolTip(tr("Write palette-%1.toml and fonts.toml, then reload the theme").arg(loadedScheme.toLower()));
|
||||
|
||||
if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) {
|
||||
saveBtn->setEnabled(false);
|
||||
|
|
@ -228,6 +282,13 @@ void PaletteEditorDialog::loadSchemes()
|
|||
}
|
||||
}
|
||||
|
||||
void PaletteEditorDialog::loadFonts()
|
||||
{
|
||||
savedFontConfig = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName);
|
||||
fontGrid->loadFonts(savedFontConfig);
|
||||
fontsDirty = false;
|
||||
}
|
||||
|
||||
void PaletteEditorDialog::seedAccentFromScheme(const QString &scheme)
|
||||
{
|
||||
QColor seed = workingConfig.value(scheme).colors.value(QPalette::Active).value(QPalette::Highlight);
|
||||
|
|
@ -258,6 +319,9 @@ void PaletteEditorDialog::onGenerateFromAccent(const QColor &accent, int intensi
|
|||
void PaletteEditorDialog::onApply()
|
||||
{
|
||||
themeManager->previewPalette(paletteGrid->currentPaletteConfig(), loadedScheme);
|
||||
if (fontsDirty) {
|
||||
themeManager->previewFonts(fontGrid->currentFontConfig());
|
||||
}
|
||||
}
|
||||
|
||||
void PaletteEditorDialog::onSave()
|
||||
|
|
@ -308,6 +372,20 @@ void PaletteEditorDialog::onSave()
|
|||
savedConfig[it.key()] = it.value();
|
||||
}
|
||||
|
||||
// Persist the shared per-theme fonts when they changed.
|
||||
if (fontsDirty) {
|
||||
const FontConfig fontCfg = fontGrid->currentFontConfig();
|
||||
if (fontCfg != savedFontConfig) {
|
||||
if (!ThemeManager::saveFontConfig(saveDir, fontCfg)) {
|
||||
QMessageBox::warning(this, tr("Save failed"),
|
||||
tr("Could not write %1 to:\n%2").arg(FontConfig::fileName(), saveDir));
|
||||
return;
|
||||
}
|
||||
savedFontConfig = fontCfg;
|
||||
}
|
||||
fontsDirty = false;
|
||||
}
|
||||
|
||||
themeManager->reloadCurrentTheme();
|
||||
accept();
|
||||
}
|
||||
|
|
@ -316,6 +394,12 @@ void PaletteEditorDialog::onReset()
|
|||
{
|
||||
workingConfig[loadedScheme] = savedConfig[loadedScheme];
|
||||
paletteGrid->loadPalette(savedConfig[loadedScheme]);
|
||||
// Restore the live theme before re-reading the grid: loadFonts resolves
|
||||
// unset roles, and the preview has currentFonts pointing at the edited
|
||||
// config until the theme is reloaded.
|
||||
themeManager->reloadCurrentTheme();
|
||||
fontGrid->loadFonts(savedFontConfig);
|
||||
fontsDirty = false;
|
||||
seedAccentFromScheme(loadedScheme);
|
||||
}
|
||||
|
||||
|
|
@ -350,6 +434,27 @@ void PaletteEditorDialog::onRevertToDefault()
|
|||
workingConfig[loadedScheme] = def;
|
||||
paletteGrid->loadPalette(def);
|
||||
seedAccentFromScheme(loadedScheme);
|
||||
|
||||
// Revert the shared per-theme fonts the same way: delete the user-written
|
||||
// fonts.toml so the theme falls back to its shipped default, then reflect
|
||||
// the resolved fonts in the editor.
|
||||
QFile::remove(QDir(saveDir).absoluteFilePath(FontConfig::fileName()));
|
||||
|
||||
// Reload the live theme so the font revert takes effect immediately.
|
||||
themeManager->reloadCurrentTheme();
|
||||
|
||||
const FontConfig fontDef = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName);
|
||||
savedFontConfig = fontDef;
|
||||
fontGrid->loadFonts(fontDef);
|
||||
fontsDirty = false;
|
||||
}
|
||||
|
||||
void PaletteEditorDialog::reject()
|
||||
{
|
||||
// Discard any unsaved live preview (palette and fonts) so closing without
|
||||
// Save restores the active theme instead of leaking the preview globally.
|
||||
themeManager->reloadCurrentTheme();
|
||||
QDialog::reject();
|
||||
}
|
||||
|
||||
void PaletteEditorDialog::changeEvent(QEvent *e)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ class QComboBox;
|
|||
class QDialogButtonBox;
|
||||
class QPushButton;
|
||||
class PaletteGridWidget;
|
||||
class FontGridWidget;
|
||||
class QuickSetupPanel;
|
||||
class QFrame;
|
||||
class QWidget;
|
||||
|
|
@ -25,6 +26,7 @@ class PaletteEditorDialog : public QDialog
|
|||
public:
|
||||
explicit PaletteEditorDialog(const QString &themeDirPath, const QString &themeName, QWidget *parent = nullptr);
|
||||
void loadSchemes();
|
||||
void reject() override;
|
||||
|
||||
private slots:
|
||||
void onSave();
|
||||
|
|
@ -38,8 +40,10 @@ private:
|
|||
void setupUi();
|
||||
void retranslateUi();
|
||||
void refreshChromePalettes();
|
||||
void updateAdvancedHeight();
|
||||
void loadScheme(const QString &scheme); // snapshot current, switch, load
|
||||
void seedAccentFromScheme(const QString &scheme);
|
||||
void loadFonts(); // shared per theme, not split by light/dark
|
||||
|
||||
// Sub-widgets
|
||||
QWidget *header;
|
||||
|
|
@ -49,6 +53,11 @@ private:
|
|||
PaletteGridWidget *paletteGrid = nullptr;
|
||||
QPushButton *paletteGridToggleButton = nullptr;
|
||||
QFrame *paletteGridSeparator = nullptr;
|
||||
FontGridWidget *fontGrid = nullptr;
|
||||
QPushButton *fontToggleButton = nullptr;
|
||||
QFrame *fontSeparator = nullptr;
|
||||
QWidget *fontSection = nullptr;
|
||||
QLabel *fontNoteLabel = nullptr;
|
||||
QWidget *footer;
|
||||
QComboBox *schemeComboBox = nullptr;
|
||||
QDialogButtonBox *buttonBox = nullptr;
|
||||
|
|
@ -68,10 +77,18 @@ private:
|
|||
QMap<QString, PaletteConfig> workingConfig;
|
||||
QMap<QString, PaletteConfig> savedConfig;
|
||||
|
||||
// Fonts are shared per theme (fonts.toml, not split by light/dark), so a
|
||||
// single saved config suffices; the live edits are read from fontGrid.
|
||||
FontConfig savedFontConfig;
|
||||
// Set only by user-driven fontsChanged after the initial load completes.
|
||||
// Gates both previewFonts and the fonts.toml write so a palette-only Save
|
||||
// never materializes fallback fonts to disk.
|
||||
bool fontsDirty = false;
|
||||
|
||||
QTimer *autoApplyTimer = nullptr;
|
||||
|
||||
protected:
|
||||
void changeEvent(QEvent *e) override;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_PALETTE_EDITOR_DIALOG_H
|
||||
#endif // COCKATRICE_PALETTE_EDITOR_DIALOG_H
|
||||
|
|
|
|||
|
|
@ -76,6 +76,18 @@ PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent)
|
|||
|
||||
layout = QtUtils::makeLayout<QVBoxLayout>(this);
|
||||
layout->addWidget(scroll);
|
||||
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, &PaletteGridWidget::applyInterfaceDefaultFont);
|
||||
}
|
||||
|
||||
void PaletteGridWidget::applyInterfaceDefaultFont()
|
||||
{
|
||||
// The editor's own chrome follows the InterfaceDefault theme role; bold stays caller-side.
|
||||
for (auto *label : headerLabels) {
|
||||
QFont headerFont = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
headerFont.setBold(true);
|
||||
label->setFont(headerFont);
|
||||
}
|
||||
}
|
||||
|
||||
void PaletteGridWidget::buildGrid(QWidget *host)
|
||||
|
|
@ -101,7 +113,8 @@ void PaletteGridWidget::buildGrid(QWidget *host)
|
|||
auto *label = new QLabel(groupHeaders[col], host);
|
||||
label->setAlignment(Qt::AlignCenter);
|
||||
label->setToolTip(groupTips[col]);
|
||||
label->setFont(QtUtils::boldFont(label->font()));
|
||||
// Family comes from the InterfaceDefault theme role; bold stays caller-side.
|
||||
label->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault)));
|
||||
label->setAutoFillBackground(true);
|
||||
label->setContentsMargins(4, 4, 4, 4);
|
||||
grid->addWidget(label, 0, col + 1);
|
||||
|
|
@ -146,7 +159,7 @@ void PaletteGridWidget::buildGrid(QWidget *host)
|
|||
|
||||
auto *appHeader = new QLabel(tr("App colors"), host);
|
||||
appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette"));
|
||||
appHeader->setFont(QtUtils::boldFont(appHeader->font()));
|
||||
appHeader->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault)));
|
||||
appHeader->setAutoFillBackground(true);
|
||||
appHeader->setContentsMargins(4, 4, 4, 4);
|
||||
grid->addWidget(appHeader, appHeaderRow, 0, 1, 4);
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ private:
|
|||
void changeEvent(QEvent *e);
|
||||
void refreshChromePalettes();
|
||||
|
||||
private slots:
|
||||
void applyInterfaceDefaultFont();
|
||||
|
||||
private:
|
||||
QMap<QPalette::ColorGroup, QMap<QPalette::ColorRole, ColorButton *>> colorButtons;
|
||||
QMap<AppColor::Role, ColorButton *> appColorButtons;
|
||||
QScrollArea *scroll;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "quick_setup_panel.h"
|
||||
|
||||
#include "../theme_manager.h"
|
||||
#include "color_button.h"
|
||||
|
||||
#include <QApplication>
|
||||
|
|
@ -28,10 +29,15 @@ QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent)
|
|||
|
||||
labelLow = new QLabel(this);
|
||||
labelHigh = new QLabel(this);
|
||||
QFont small = labelLow->font();
|
||||
small.setPointSizeF(small.pointSizeF() * 0.82);
|
||||
labelLow->setFont(small);
|
||||
labelHigh->setFont(small);
|
||||
// Family comes from the InterfaceDefault theme role; the *0.82 size stays caller-side.
|
||||
auto applySmallFont = [this]() {
|
||||
QFont small = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
small.setPointSizeF(small.pointSizeF() * 0.82);
|
||||
labelLow->setFont(small);
|
||||
labelHigh->setFont(small);
|
||||
};
|
||||
applySmallFont();
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, applySmallFont);
|
||||
QPalette dimmed = labelLow->palette();
|
||||
dimmed.setColor(QPalette::WindowText, qApp->palette().color(QPalette::Mid));
|
||||
labelLow->setPalette(dimmed);
|
||||
|
|
|
|||
102
cockatrice/src/interface/recording.cpp
Normal file
102
cockatrice/src/interface/recording.cpp
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
#include "recording.h"
|
||||
|
||||
#include "pqEventTypes.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QTextStream>
|
||||
#include <QXmlStreamReader>
|
||||
#include <QXmlStreamWriter>
|
||||
|
||||
XMLEventObserver::XMLEventObserver(QObject *parent) : pqEventObserver(parent)
|
||||
{
|
||||
}
|
||||
|
||||
XMLEventObserver::~XMLEventObserver() = default;
|
||||
|
||||
void XMLEventObserver::setStream(QTextStream *stream)
|
||||
{
|
||||
if (xmlStream) {
|
||||
xmlStream->writeEndElement();
|
||||
xmlStream->writeEndDocument();
|
||||
xmlStream.reset();
|
||||
}
|
||||
if (Stream) {
|
||||
*Stream << xmlString;
|
||||
}
|
||||
xmlString.clear();
|
||||
pqEventObserver::setStream(stream);
|
||||
if (Stream) {
|
||||
xmlStream.reset(new QXmlStreamWriter(&xmlString));
|
||||
xmlStream->setAutoFormatting(true);
|
||||
xmlStream->writeStartDocument();
|
||||
xmlStream->writeStartElement(QStringLiteral("events"));
|
||||
}
|
||||
}
|
||||
|
||||
void XMLEventObserver::onRecordEvent(const QString &widget,
|
||||
const QString &command,
|
||||
const QString &arguments,
|
||||
const int &eventType)
|
||||
{
|
||||
if (!xmlStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
xmlStream->writeStartElement(QStringLiteral("event"));
|
||||
xmlStream->writeAttribute(QStringLiteral("widget"), widget);
|
||||
if (eventType == pqEventTypes::ACTION_EVENT) {
|
||||
xmlStream->writeAttribute(QStringLiteral("command"), command);
|
||||
} else { // pqEventTypes::CHECK_EVENT
|
||||
xmlStream->writeAttribute(QStringLiteral("property"), command);
|
||||
}
|
||||
xmlStream->writeAttribute(QStringLiteral("arguments"), arguments);
|
||||
xmlStream->writeEndElement();
|
||||
}
|
||||
|
||||
XMLEventSource::XMLEventSource(QObject *parent) : pqEventSource(parent)
|
||||
{
|
||||
}
|
||||
|
||||
XMLEventSource::~XMLEventSource() = default;
|
||||
|
||||
void XMLEventSource::setContent(const QString &xmlFileName)
|
||||
{
|
||||
xmlStream.reset();
|
||||
|
||||
QFile xml(xmlFileName);
|
||||
if (!xml.open(QIODevice::ReadOnly)) {
|
||||
qCWarning(RecordingLog) << "Could not read test file" << xmlFileName;
|
||||
return;
|
||||
}
|
||||
|
||||
xmlStream.reset(new QXmlStreamReader(xml.readAll()));
|
||||
}
|
||||
|
||||
int XMLEventSource::getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType)
|
||||
{
|
||||
if (!xmlStream) {
|
||||
return DONE;
|
||||
}
|
||||
|
||||
while (!xmlStream->atEnd()) {
|
||||
if (xmlStream->readNext() != QXmlStreamReader::StartElement || xmlStream->name() != QStringLiteral("event")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const QXmlStreamAttributes attributes = xmlStream->attributes();
|
||||
eventType = pqEventTypes::ACTION_EVENT;
|
||||
widget = attributes.value(QStringLiteral("widget")).toString();
|
||||
command = attributes.value(QStringLiteral("command")).toString();
|
||||
if (command.isEmpty()) {
|
||||
// A recorded check event carries a property instead of a command.
|
||||
command = attributes.value(QStringLiteral("property")).toString();
|
||||
}
|
||||
arguments = attributes.value(QStringLiteral("arguments")).toString();
|
||||
return SUCCESS;
|
||||
}
|
||||
|
||||
if (xmlStream->hasError()) {
|
||||
qCWarning(RecordingLog) << "Malformed test file:" << xmlStream->errorString();
|
||||
}
|
||||
return DONE;
|
||||
}
|
||||
54
cockatrice/src/interface/recording.h
Normal file
54
cockatrice/src/interface/recording.h
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
#ifndef COCKATRICE_RECORDING_H
|
||||
#define COCKATRICE_RECORDING_H
|
||||
|
||||
#include "pqEventObserver.h"
|
||||
#include "pqEventSource.h"
|
||||
|
||||
#include <QLoggingCategory>
|
||||
#include <QScopedPointer>
|
||||
|
||||
inline Q_LOGGING_CATEGORY(RecordingLog, "recording");
|
||||
|
||||
class QXmlStreamReader;
|
||||
class QXmlStreamWriter;
|
||||
|
||||
/**
|
||||
* @brief Writes a session recording as an XML test file.
|
||||
*
|
||||
* The events are buffered in memory and flushed to the stream when recording
|
||||
* ends, because the stream is handed over once the file name is known.
|
||||
*/
|
||||
class XMLEventObserver : public pqEventObserver
|
||||
{
|
||||
QScopedPointer<QXmlStreamWriter> xmlStream;
|
||||
QString xmlString;
|
||||
|
||||
public:
|
||||
explicit XMLEventObserver(QObject *parent);
|
||||
~XMLEventObserver() override;
|
||||
|
||||
protected:
|
||||
void setStream(QTextStream *stream) override;
|
||||
void onRecordEvent(const QString &widget,
|
||||
const QString &command,
|
||||
const QString &arguments,
|
||||
const int &eventType) override;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Plays the events of an XML test file back, one at a time.
|
||||
*/
|
||||
class XMLEventSource : public pqEventSource
|
||||
{
|
||||
QScopedPointer<QXmlStreamReader> xmlStream;
|
||||
|
||||
public:
|
||||
explicit XMLEventSource(QObject *parent);
|
||||
~XMLEventSource() override;
|
||||
|
||||
protected:
|
||||
void setContent(const QString &xmlFileName) override;
|
||||
int getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType) override;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_RECORDING_H
|
||||
|
|
@ -304,4 +304,183 @@ QPalette PaletteConfig::apply(QPalette base) const
|
|||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
bool FontConfig::hasFonts() const
|
||||
{
|
||||
return !fonts.isEmpty() || cardTextMinSize != 1 || cardTextMaxSize > 0;
|
||||
}
|
||||
|
||||
QString FontConfig::toToml() const
|
||||
{
|
||||
QMetaEnum roleEnum = QMetaEnum::fromType<AppFont::Role>();
|
||||
|
||||
QString entries;
|
||||
|
||||
for (auto it = fonts.cbegin(); it != fonts.cend(); ++it) {
|
||||
const char *roleName = roleEnum.valueToKey(it.key());
|
||||
|
||||
if (!roleName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
QString family = it.value().family().trimmed();
|
||||
int pointSize = it.value().pointSize();
|
||||
|
||||
if (family.isEmpty() || pointSize <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries += QString("%1 = \"%2, %3\"\n").arg(QString(roleName), -20).arg(family).arg(pointSize);
|
||||
}
|
||||
|
||||
if (cardTextMinSize != 1) {
|
||||
entries += QString("%1 = %2\n").arg(QStringLiteral("CardTextMinSize"), -20).arg(cardTextMinSize);
|
||||
}
|
||||
|
||||
if (cardTextMaxSize > 0) {
|
||||
entries += QString("%1 = %2\n").arg(QStringLiteral("CardTextMaxSize"), -20).arg(cardTextMaxSize);
|
||||
}
|
||||
|
||||
if (entries.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return "[Fonts]\n" + entries + "\n";
|
||||
}
|
||||
|
||||
QString FontConfig::fileName()
|
||||
{
|
||||
return "fonts.toml";
|
||||
}
|
||||
|
||||
FontConfig FontConfig::fromFile(const QString &filePath)
|
||||
{
|
||||
FontConfig cfg;
|
||||
|
||||
QFile f(filePath);
|
||||
|
||||
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
QMetaEnum roleEnum = QMetaEnum::fromType<AppFont::Role>();
|
||||
|
||||
QString currentSection;
|
||||
|
||||
QTextStream in(&f);
|
||||
|
||||
while (!in.atEnd()) {
|
||||
QString line = in.readLine().trimmed();
|
||||
|
||||
if (line.isEmpty() || line.startsWith('#') || line.startsWith(';')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('[') && line.endsWith(']')) {
|
||||
currentSection = line.mid(1, line.length() - 2).trimmed();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentSection.compare("Fonts", Qt::CaseInsensitive) != 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int eq = line.indexOf('=');
|
||||
|
||||
if (eq < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
QString key = line.left(eq).trimmed();
|
||||
QString value = line.mid(eq + 1).trimmed();
|
||||
|
||||
// Strip inline comments if preceded by whitespace
|
||||
for (int i = 1; i < value.size(); ++i) {
|
||||
if ((value[i] == '#' || value[i] == ';') && value[i - 1].isSpace()) {
|
||||
value = value.left(i).trimmed();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Strip surrounding quotes, if any
|
||||
if (value.size() >= 2 &&
|
||||
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')))) {
|
||||
value = value.mid(1, value.size() - 2).trimmed();
|
||||
}
|
||||
|
||||
// CardText size bounds: bare integers, e.g. CardTextMinSize = 2.
|
||||
// Tolerate quoting like the family entries; skip invalid lines.
|
||||
QString sizeKey = key;
|
||||
if (sizeKey.startsWith("AppFont::")) {
|
||||
sizeKey = sizeKey.mid(QStringLiteral("AppFont::").size());
|
||||
}
|
||||
if (sizeKey.compare("CardTextMinSize", Qt::CaseInsensitive) == 0) {
|
||||
bool ok = false;
|
||||
int minSize = value.trimmed().toInt(&ok);
|
||||
if (!ok || minSize <= 0) {
|
||||
continue;
|
||||
}
|
||||
cfg.cardTextMinSize = minSize;
|
||||
continue;
|
||||
}
|
||||
if (sizeKey.compare("CardTextMaxSize", Qt::CaseInsensitive) == 0) {
|
||||
bool ok = false;
|
||||
int maxSize = value.trimmed().toInt(&ok);
|
||||
if (!ok || maxSize <= 0) {
|
||||
continue;
|
||||
}
|
||||
cfg.cardTextMaxSize = maxSize;
|
||||
continue;
|
||||
}
|
||||
|
||||
int comma = value.lastIndexOf(',');
|
||||
|
||||
if (comma < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
QString family = value.left(comma).trimmed();
|
||||
|
||||
// Tolerate a partially-quoted family, e.g. "Inter", 10
|
||||
if (family.size() >= 2 &&
|
||||
((family.startsWith('"') && family.endsWith('"')) || (family.startsWith('\'') && family.endsWith('\'')))) {
|
||||
family = family.mid(1, family.size() - 2).trimmed();
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
int pointSize = value.mid(comma + 1).trimmed().toInt(&ok);
|
||||
|
||||
if (!ok || family.isEmpty() || pointSize <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key.startsWith("AppFont::")) {
|
||||
key = key.mid(QStringLiteral("AppFont::").size());
|
||||
}
|
||||
|
||||
int roleInt = roleEnum.keyToValue(key.toUtf8().constData());
|
||||
|
||||
if (roleInt < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Canonical family + point size only: a fresh QFont carries default
|
||||
// (non-bold, non-italic) weight/style, so nothing caller-side leaks in.
|
||||
// No validation against installed fonts; Qt substitution applies.
|
||||
QFont font(family);
|
||||
font.setPointSize(pointSize);
|
||||
cfg.fonts[static_cast<AppFont::Role>(roleInt)] = font;
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
FontConfig FontConfig::fromThemeDir(const QString &themeDirPath)
|
||||
{
|
||||
if (themeDirPath.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return fromFile(QDir(themeDirPath).absoluteFilePath(fileName()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#define COCKATRICE_THEME_CONFIG_H
|
||||
|
||||
#include <QColor>
|
||||
#include <QFont>
|
||||
#include <QMap>
|
||||
#include <QPalette>
|
||||
#include <QSharedPointer>
|
||||
|
|
@ -23,6 +24,23 @@ enum Role
|
|||
Q_ENUM_NS(Role)
|
||||
} // namespace AppColor
|
||||
|
||||
// Application-specific font roles, one configurable family + point size per
|
||||
// purpose. Stored in a shared fonts.toml under a [Fonts] section (one file per
|
||||
// theme, not split by light/dark). Only family and point size are stored;
|
||||
// weight/italic are stripped on load and stay caller-side concerns.
|
||||
namespace AppFont
|
||||
{
|
||||
Q_NAMESPACE
|
||||
enum Role
|
||||
{
|
||||
InterfaceDefault,
|
||||
ChatLog,
|
||||
CardText,
|
||||
DeckList,
|
||||
};
|
||||
Q_ENUM_NS(Role)
|
||||
} // namespace AppFont
|
||||
|
||||
struct ThemeConfig
|
||||
{
|
||||
QString colorScheme;
|
||||
|
|
@ -60,4 +78,41 @@ struct PaletteConfig
|
|||
QPalette apply(QPalette base) const;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_THEME_CONFIG_H
|
||||
/** @brief Per-purpose font configuration, stored as family + point size only.
|
||||
*
|
||||
* Serialized to a shared fonts.toml file with a [Fonts] section, e.g.
|
||||
* @code
|
||||
* [Fonts]
|
||||
* InterfaceDefault = "Inter, 10"
|
||||
* CardText = "Noto Serif, 12"
|
||||
* CardTextMinSize = 1
|
||||
* @endcode
|
||||
* CardText stores family + MAX size; cardTextMinSize stores the overlay
|
||||
* shrink-to-fit floor (omitted from TOML when 1).
|
||||
*/
|
||||
struct FontConfig
|
||||
{
|
||||
QMap<AppFont::Role, QFont> fonts;
|
||||
int cardTextMinSize = 1;
|
||||
/// Optional size-only maximum for CardText, used by the retired maxFontSize
|
||||
/// migration so seeding it does not have to pin a family.
|
||||
int cardTextMaxSize = 0;
|
||||
|
||||
bool operator==(const FontConfig &rhs) const
|
||||
{
|
||||
return fonts == rhs.fonts && cardTextMinSize == rhs.cardTextMinSize && cardTextMaxSize == rhs.cardTextMaxSize;
|
||||
}
|
||||
bool operator!=(const FontConfig &rhs) const
|
||||
{
|
||||
return !(*this == rhs);
|
||||
}
|
||||
bool hasFonts() const;
|
||||
QString toToml() const;
|
||||
|
||||
static QString fileName();
|
||||
|
||||
static FontConfig fromFile(const QString &filePath);
|
||||
static FontConfig fromThemeDir(const QString &themeDirPath);
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_THEME_CONFIG_H
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QFlags>
|
||||
#include <QFontDatabase>
|
||||
#include <QGuiApplication>
|
||||
#include <QIODevice>
|
||||
#include <QLatin1Char>
|
||||
|
|
@ -127,8 +128,10 @@ static QString usableDefaultStyle(const QString &style)
|
|||
ThemeManager::ThemeManager(QObject *parent) : QObject(parent)
|
||||
{
|
||||
defaultStyleName = usableDefaultStyle(qApp->style()->objectName());
|
||||
// Capture the untouched application palette before any theme is applied.
|
||||
// Capture the untouched application palette and font before any theme is
|
||||
// applied.
|
||||
defaultPalette = qApp->palette();
|
||||
defaultFont = qApp->font();
|
||||
ensureThemeDirectoryExists();
|
||||
#if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0))
|
||||
connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] {
|
||||
|
|
@ -440,6 +443,46 @@ PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath
|
|||
return cfg;
|
||||
}
|
||||
|
||||
FontConfig ThemeManager::loadFontConfig(const QString &themeDirPath)
|
||||
{
|
||||
if (themeDirPath.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
return FontConfig::fromThemeDir(themeDirPath);
|
||||
}
|
||||
|
||||
FontConfig ThemeManager::loadDefaultFontConfig(const QString &themeDirPath, const QString &themeName)
|
||||
{
|
||||
FontConfig cfg = FontConfig::fromThemeDir(themeDirPath);
|
||||
if (!cfg.hasFonts()) {
|
||||
// The shipped fonts.toml may live in the system theme directory rather
|
||||
// than the resolved (user) theme directory, so built-in themes still
|
||||
// get their curated fonts.
|
||||
cfg = FontConfig::fromThemeDir(QDir(systemThemesBasePath()).absoluteFilePath(themeName));
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
bool ThemeManager::saveFontConfig(const QString &themeDirPath, const FontConfig &cfg)
|
||||
{
|
||||
if (themeDirPath.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QDir dir(themeDirPath);
|
||||
if (!dir.exists()) {
|
||||
dir.mkpath(".");
|
||||
}
|
||||
|
||||
QFile f(dir.absoluteFilePath(FontConfig::fileName()));
|
||||
if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QTextStream(&f) << cfg.toToml();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg)
|
||||
{
|
||||
if (!savePaletteConfig(themeDirPath, colorScheme, cfg)) {
|
||||
|
|
@ -488,6 +531,24 @@ void ThemeManager::previewPalette(const PaletteConfig &cfg, const QString &schem
|
|||
applyStyleAndPalette(themeName, themeCfg, cfg, scheme);
|
||||
}
|
||||
|
||||
void ThemeManager::previewFonts(const FontConfig &cfg)
|
||||
{
|
||||
currentFonts = cfg;
|
||||
|
||||
// QApplication::setFont already sends a font change to every widget, so a
|
||||
// repolish pass is not needed for fonts (unlike the palette path).
|
||||
const auto it = cfg.fonts.constFind(AppFont::InterfaceDefault);
|
||||
if (it != cfg.fonts.constEnd()) {
|
||||
qApp->setFont(it.value());
|
||||
} else {
|
||||
// A config without InterfaceDefault means "no override": restore the
|
||||
// startup font instead of leaving the last preview applied.
|
||||
qApp->setFont(defaultFont);
|
||||
}
|
||||
|
||||
emit fontsChanged();
|
||||
}
|
||||
|
||||
void ThemeManager::applyStyleAndPalette(const QString &themeName,
|
||||
const ThemeConfig &themeCfg,
|
||||
const PaletteConfig &palCfg,
|
||||
|
|
@ -591,6 +652,80 @@ QColor ThemeManager::appColor(AppColor::Role role) const
|
|||
return accent;
|
||||
}
|
||||
|
||||
QFont ThemeManager::appFont(AppFont::Role role) const
|
||||
{
|
||||
const auto it = currentFonts.fonts.constFind(role);
|
||||
if (it != currentFonts.fonts.constEnd()) {
|
||||
return it.value();
|
||||
}
|
||||
|
||||
// Role fallbacks preserve the pre-theme defaults when no fonts.toml is
|
||||
// present: an unset role resolves to the application font. Surfaces that
|
||||
// hardcoded a different family ask for it explicitly: counter painters
|
||||
// through cardTextFont(), the deck view headers and printed deck list
|
||||
// through deckListFont(), the report dialogs through
|
||||
// report_utils::reportLogFont(). Only the family basis is fixed there;
|
||||
// sizes stay caller-side.
|
||||
return qApp->font();
|
||||
}
|
||||
|
||||
bool ThemeManager::hasAppFont(AppFont::Role role) const
|
||||
{
|
||||
return currentFonts.fonts.contains(role);
|
||||
}
|
||||
|
||||
QFont ThemeManager::cardTextFont() const
|
||||
{
|
||||
if (hasAppFont(AppFont::CardText)) {
|
||||
return appFont(AppFont::CardText);
|
||||
}
|
||||
QFont serifFont(QStringLiteral("Serif"));
|
||||
serifFont.setStyleHint(QFont::Serif);
|
||||
serifFont.setPointSize(12);
|
||||
return serifFont;
|
||||
}
|
||||
|
||||
QFont ThemeManager::deckListFont() const
|
||||
{
|
||||
if (hasAppFont(AppFont::DeckList)) {
|
||||
return appFont(AppFont::DeckList);
|
||||
}
|
||||
QFont serifFont(QStringLiteral("Serif"));
|
||||
serifFont.setStyleHint(QFont::Serif);
|
||||
return serifFont;
|
||||
}
|
||||
|
||||
QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const
|
||||
{
|
||||
// No per-role scaling and no preview: an unset role resolves to the font
|
||||
// captured at startup, which is what the theme falls back to once the
|
||||
// editor closes.
|
||||
return defaultFont;
|
||||
}
|
||||
|
||||
int ThemeManager::cardTextMaxSize() const
|
||||
{
|
||||
if (currentFonts.cardTextMaxSize > 0) {
|
||||
return currentFonts.cardTextMaxSize;
|
||||
}
|
||||
const auto it = currentFonts.fonts.constFind(AppFont::CardText);
|
||||
if (it != currentFonts.fonts.constEnd()) {
|
||||
const int size = it.value().pointSize();
|
||||
if (size > 0) {
|
||||
return size;
|
||||
}
|
||||
}
|
||||
return 12;
|
||||
}
|
||||
|
||||
int ThemeManager::cardTextMinSize() const
|
||||
{
|
||||
if (currentFonts.cardTextMinSize > 0) {
|
||||
return currentFonts.cardTextMinSize;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void ThemeManager::themeChangedSlot()
|
||||
{
|
||||
QString themeName = SettingsCache::instance().getThemeName();
|
||||
|
|
@ -642,6 +777,19 @@ void ThemeManager::themeChangedSlot()
|
|||
|
||||
applyStyleAndPalette(themeName, themeCfg, palette, activeScheme);
|
||||
|
||||
// ── Load fonts: shared per theme, no light/dark split ───────────────────
|
||||
FontConfig fonts = loadDefaultFontConfig(dirPath, themeName);
|
||||
currentFonts = fonts;
|
||||
const auto fontIt = currentFonts.fonts.constFind(AppFont::InterfaceDefault);
|
||||
if (fontIt != currentFonts.fonts.constEnd()) {
|
||||
qApp->setFont(fontIt.value());
|
||||
} else {
|
||||
// No theme font: restore the pristine application font captured at
|
||||
// startup, so a reverted preview cannot leak into the live theme.
|
||||
qApp->setFont(defaultFont);
|
||||
}
|
||||
emit fontsChanged();
|
||||
|
||||
QStringList resources;
|
||||
if (!dirPath.isEmpty()) {
|
||||
resources << dir.absolutePath();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
|
||||
#include <QBrush>
|
||||
#include <QColor>
|
||||
#include <QFont>
|
||||
#include <QLoggingCategory>
|
||||
#include <QMap>
|
||||
#include <QObject>
|
||||
|
|
@ -47,10 +48,15 @@ private:
|
|||
// palette is applied. Used as the base when a theme supplies no palette, so
|
||||
// switching away from a custom palette restores the original colours.
|
||||
QPalette defaultPalette;
|
||||
// Pristine application font captured at startup, before any theme font is
|
||||
// applied. Restored when a theme (or a preview) supplies no InterfaceDefault
|
||||
// font, so switching away from a themed font cannot leave it behind.
|
||||
QFont defaultFont;
|
||||
QString currentThemePath;
|
||||
std::array<QBrush, Role::MaxRole + 1> brushes;
|
||||
QStringMap availableThemes;
|
||||
QMap<AppColor::Role, QColor> currentAppColors;
|
||||
FontConfig currentFonts;
|
||||
/*
|
||||
Internal cache for multiple backgrounds
|
||||
*/
|
||||
|
|
@ -105,6 +111,9 @@ public:
|
|||
// Load/save per-scheme palette colors
|
||||
static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme);
|
||||
static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg);
|
||||
// Load/save shared per-theme fonts (fonts.toml, not split by scheme)
|
||||
static FontConfig loadFontConfig(const QString &themeDirPath);
|
||||
static bool saveFontConfig(const QString &themeDirPath, const FontConfig &cfg);
|
||||
// Resolve prefix to a scheme-qualified "theme:" path. Existence is probed
|
||||
// internally across the formats themes may ship (.png/.jpg/.svg), so
|
||||
// callers load the returned path directly. Prefers "<prefix>-<dark|light>"
|
||||
|
|
@ -123,6 +132,9 @@ public:
|
|||
// theme directory when it is absent from the resolved (user) directory.
|
||||
static PaletteConfig
|
||||
loadDefaultPaletteConfig(const QString &themeDirPath, const QString &themeName, const QString &colorScheme);
|
||||
// Load the theme's shipped default fonts, falling back to the system theme
|
||||
// directory when fonts.toml is absent from the resolved (user) directory.
|
||||
static FontConfig loadDefaultFontConfig(const QString &themeDirPath, const QString &themeName);
|
||||
/** @brief Writes cfg to disk as the theme's palette-<scheme>.toml and updates the
|
||||
* theme's stored colour scheme to match. Shared by PaletteEditorDialog::onSave
|
||||
* and FirstRunWizard's theme step so the two "generate + keep" paths can't drift. */
|
||||
|
|
@ -132,10 +144,36 @@ public:
|
|||
|
||||
void reloadCurrentTheme();
|
||||
void previewPalette(const PaletteConfig &cfg, const QString &scheme);
|
||||
void previewFonts(const FontConfig &cfg);
|
||||
|
||||
// Resolves an application color role: the theme's stored [AppColors] value
|
||||
// when present, otherwise a palette-accent-derived fallback.
|
||||
QColor appColor(AppColor::Role role) const;
|
||||
/** @brief Resolves an application font role: the theme's stored [Fonts]
|
||||
* value when present, otherwise the application font. Roles that
|
||||
* had a historical hardcoded family ask for it through
|
||||
* cardTextFont() and deckListFont() instead. */
|
||||
QFont appFont(AppFont::Role role) const;
|
||||
/** @brief Whether the active theme defines this role in fonts.toml. */
|
||||
bool hasAppFont(AppFont::Role role) const;
|
||||
/** @brief The CardText role with its historical Serif fallback. Used by the
|
||||
* counter painters, which were Serif before the role existed. */
|
||||
QFont cardTextFont() const;
|
||||
/** @brief The DeckList role with its historical Serif fallback. Used by the
|
||||
* two surfaces that hardcoded Serif before the role existed (the
|
||||
* deck view zone headers and the printed deck list); every other
|
||||
* DeckList reader falls back to the application font. */
|
||||
QFont deckListFont() const;
|
||||
/** @brief Resolves an application font role against the startup font only,
|
||||
* ignoring any previewed config in currentFonts. Editors that show
|
||||
* "what this role resolves to" must use this, because appFont()
|
||||
* reflects a live preview while the editor is open. */
|
||||
QFont defaultAppFont(AppFont::Role role) const;
|
||||
/** @brief CardText max size: stored CardTextMaxSize, else the CardText
|
||||
* point size, else 12. */
|
||||
int cardTextMaxSize() const;
|
||||
/** @brief CardText shrink-to-fit floor: stored CardTextMinSize, else 1. */
|
||||
int cardTextMinSize() const;
|
||||
|
||||
QBrush &getBgBrush(Role zone);
|
||||
QBrush getExtraBgBrush(Role zone, int zoneId = 0);
|
||||
|
|
@ -144,6 +182,7 @@ protected slots:
|
|||
signals:
|
||||
void themeChanged();
|
||||
void paletteChanged();
|
||||
void fontsChanged();
|
||||
};
|
||||
|
||||
extern ThemeManager *themeManager;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "art_crop_attribution.h"
|
||||
|
||||
#include "../../theme_manager.h"
|
||||
#include "libcockatrice/card/printing/printing_info.h"
|
||||
|
||||
#include <QColor>
|
||||
|
|
@ -35,7 +36,10 @@ QRectF paintArtAttribution(QPainter &painter,
|
|||
|
||||
painter.save();
|
||||
|
||||
// Family comes from the CardText theme role; the incoming painter size
|
||||
// stays the basis for the scale factor exactly as before.
|
||||
QFont font = painter.font();
|
||||
font.setFamily(themeManager->appFont(AppFont::CardText).family());
|
||||
font.setPointSizeF(qMax(6.0, font.pointSizeF() * scale));
|
||||
painter.setFont(font);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#include "card_info_picture_with_text_overlay_widget.h"
|
||||
|
||||
#include "../../theme_manager.h"
|
||||
|
||||
#include <QBrush>
|
||||
#include <QChar>
|
||||
#include <QFont>
|
||||
|
|
@ -43,6 +45,7 @@ CardInfoPictureWithTextOverlayWidget::CardInfoPictureWithTextOverlayWidget(QWidg
|
|||
fontSize(fontSize), textAlignment(alignment), highlighted(false)
|
||||
{
|
||||
this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, &CardInfoPictureWithTextOverlayWidget::applyCardTextFont);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -84,6 +87,7 @@ void CardInfoPictureWithTextOverlayWidget::setOutlineColor(const QColor &color)
|
|||
void CardInfoPictureWithTextOverlayWidget::setFontSize(const int size)
|
||||
{
|
||||
fontSize = size > 0 ? size : 1;
|
||||
hasExplicitFontSize = true;
|
||||
update();
|
||||
}
|
||||
|
||||
|
|
@ -97,6 +101,13 @@ void CardInfoPictureWithTextOverlayWidget::setTextAlignment(const Qt::Alignment
|
|||
update();
|
||||
}
|
||||
|
||||
void CardInfoPictureWithTextOverlayWidget::applyCardTextFont()
|
||||
{
|
||||
// The overlay paint reads the CardText role font at paint time, so a
|
||||
// theme font change only needs a repaint here.
|
||||
update();
|
||||
}
|
||||
|
||||
void CardInfoPictureWithTextOverlayWidget::setHighlighted(bool _highlighted)
|
||||
{
|
||||
if (highlighted == _highlighted) {
|
||||
|
|
@ -168,9 +179,13 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event)
|
|||
return;
|
||||
}
|
||||
|
||||
// Calculate the optimal font size
|
||||
QFont font = painter.font();
|
||||
int optimalFontSize = fontSize; // Start with the user-defined font size
|
||||
// Calculate the optimal font size. The family comes from the CardText
|
||||
// theme role; the start size is the explicit size when set (e.g. deck
|
||||
// preview 24), else the theme max, shrinking down to the theme min floor.
|
||||
QFont font = themeManager->appFont(AppFont::CardText);
|
||||
const int themeMin = std::max(1, themeManager->cardTextMinSize());
|
||||
const int themeMax = std::max(themeMin, std::max(1, themeManager->cardTextMaxSize()));
|
||||
int optimalFontSize = hasExplicitFontSize ? std::max(1, fontSize) : themeMax;
|
||||
const QFontMetrics baseMetrics(font);
|
||||
int textWidth = pixmapRect.width();
|
||||
|
||||
|
|
@ -188,7 +203,7 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event)
|
|||
}
|
||||
|
||||
--optimalFontSize;
|
||||
} while (optimalFontSize > 1);
|
||||
} while (optimalFontSize > themeMin);
|
||||
|
||||
// Apply the calculated font size
|
||||
painter.setFont(font);
|
||||
|
|
@ -215,7 +230,7 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event)
|
|||
int totalTextHeight = wrappedText.count('\n') * fontMetrics.height() + fontMetrics.height();
|
||||
|
||||
// Adjust font size if the total text height exceeds the pixmap height
|
||||
while (totalTextHeight > pixmapRect.height() && optimalFontSize > 1) {
|
||||
while (totalTextHeight > pixmapRect.height() && optimalFontSize > themeMin) {
|
||||
--optimalFontSize;
|
||||
font.setPointSize(optimalFontSize);
|
||||
painter.setFont(font);
|
||||
|
|
@ -284,8 +299,10 @@ QSize CardInfoPictureWithTextOverlayWidget::minimumSizeHint() const
|
|||
const QSize pixmapSize = pixmap.isNull() ? QSize(0, 0) : pixmap.size();
|
||||
|
||||
// Get the font metrics for the overlay text
|
||||
QFont font;
|
||||
font.setPointSize(fontSize);
|
||||
QFont font = themeManager->appFont(AppFont::CardText);
|
||||
const int themeMin = std::max(1, themeManager->cardTextMinSize());
|
||||
const int themeMax = std::max(themeMin, std::max(1, themeManager->cardTextMaxSize()));
|
||||
font.setPointSize(hasExplicitFontSize ? std::max(1, fontSize) : themeMax);
|
||||
const QFontMetrics fontMetrics(font);
|
||||
|
||||
// Calculate the height required for the text
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ protected:
|
|||
void mousePressEvent(QMouseEvent *event) override;
|
||||
[[nodiscard]] QSize minimumSizeHint() const override;
|
||||
|
||||
private slots:
|
||||
void applyCardTextFont();
|
||||
|
||||
private:
|
||||
void drawOutlinedText(QPainter &painter,
|
||||
const QRect &textRect,
|
||||
|
|
@ -57,6 +60,7 @@ private:
|
|||
QColor textColor;
|
||||
QColor outlineColor;
|
||||
int fontSize;
|
||||
bool hasExplicitFontSize = false;
|
||||
Qt::Alignment textAlignment;
|
||||
bool highlighted;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "resizable_panel.h"
|
||||
|
||||
#include "../interface/theme_manager.h"
|
||||
#include "abstract_analytics_panel_widget.h"
|
||||
#include "libcockatrice/utility/qt_utils.h"
|
||||
|
||||
|
|
@ -12,6 +13,7 @@
|
|||
#include <QDropEvent>
|
||||
#include <QEvent>
|
||||
#include <QFlags>
|
||||
#include <QFont>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
#include <QMimeData>
|
||||
|
|
@ -93,9 +95,17 @@ ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWid
|
|||
|
||||
dragButton->setStyleSheet(QString("QPushButton { "
|
||||
"background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 %1, stop:1 %2); "
|
||||
"border: none; color: %3; font-size: 10px; }"
|
||||
"border: none; color: %3; }"
|
||||
"QPushButton:hover { background: %4; }")
|
||||
.arg(mid.name(), dark.name(), placeholderText.name(), midLight.name()));
|
||||
// Family comes from the InterfaceDefault theme role; the 10px size stays caller-side.
|
||||
auto applyDragFont = [this]() {
|
||||
QFont dragFont = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
dragFont.setPixelSize(10);
|
||||
dragButton->setFont(dragFont);
|
||||
};
|
||||
applyDragFont();
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, applyDragFont);
|
||||
|
||||
resizeHandle->setStyleSheet(QString("QWidget { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, "
|
||||
"stop:0 %1, stop:1 %2); }")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#include "deck_list_style_proxy.h"
|
||||
|
||||
#include "../../theme_manager.h"
|
||||
|
||||
#include <QBrush>
|
||||
#include <QColor>
|
||||
#include <QFont>
|
||||
|
|
@ -8,6 +10,16 @@
|
|||
#include <libcockatrice/models/deck_list/deck_list_model.h>
|
||||
#include <qnamespace.h>
|
||||
|
||||
DeckListStyleProxy::DeckListStyleProxy(QObject *parent) : QIdentityProxyModel(parent)
|
||||
{
|
||||
// FontRole data reads the DeckList role live; rebroadcast so attached
|
||||
// views re-query instead of waiting for an incidental repaint.
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
|
||||
emit layoutAboutToBeChanged();
|
||||
emit layoutChanged();
|
||||
});
|
||||
}
|
||||
|
||||
QVariant DeckListStyleProxy::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
QModelIndex src = mapToSource(index);
|
||||
|
|
@ -20,7 +32,8 @@ QVariant DeckListStyleProxy::data(const QModelIndex &index, int role) const
|
|||
bool isCard = src.data(DeckRoles::IsCardRole).toBool();
|
||||
|
||||
if (role == Qt::FontRole && !isCard) {
|
||||
QFont f;
|
||||
// Family comes from the DeckList theme role; bold stays caller-side.
|
||||
QFont f = themeManager->appFont(AppFont::DeckList);
|
||||
f.setBold(true);
|
||||
return f;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ class DeckListStyleProxy : public QIdentityProxyModel
|
|||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
using QIdentityProxyModel::QIdentityProxyModel;
|
||||
explicit DeckListStyleProxy(QObject *parent = nullptr);
|
||||
|
||||
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class QWidget;
|
|||
|
||||
DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setObjectName("connectDialog");
|
||||
previousHostButton = new QRadioButton(tr("Known Hosts"), this);
|
||||
previousHosts = new QComboBox(this);
|
||||
|
||||
|
|
@ -85,6 +86,11 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
|
|||
passwordLabel->setBuddy(passwordEdit);
|
||||
passwordEdit->setEchoMode(QLineEdit::Password);
|
||||
|
||||
hostEdit->setObjectName("connectHostEdit");
|
||||
portEdit->setObjectName("connectPortEdit");
|
||||
playernameEdit->setObjectName("connectPlayernameEdit");
|
||||
passwordEdit->setObjectName("connectPasswordEdit");
|
||||
|
||||
savePasswordCheckBox = new QCheckBox(tr("&Save password"));
|
||||
|
||||
autoConnectCheckBox = new QCheckBox(tr("A&uto connect"));
|
||||
|
|
@ -290,11 +296,13 @@ void DlgConnect::updateDisplayInfo(const QString &saveName)
|
|||
playernameEdit->setFocus();
|
||||
savePasswordCheckBox->setChecked(savePasswordStatus);
|
||||
storedVerifier.clear();
|
||||
savedVerifier.clear();
|
||||
|
||||
if (savePasswordStatus) {
|
||||
const QString stored = _data.at(4);
|
||||
if (stored.startsWith("$")) {
|
||||
storedVerifier = stored;
|
||||
savedVerifier = stored;
|
||||
passwordEdit->clear();
|
||||
passwordEdit->setPlaceholderText(tr("Password saved"));
|
||||
} else {
|
||||
|
|
@ -331,6 +339,7 @@ void DlgConnect::newHostSelected(bool state)
|
|||
passwordEdit->clear();
|
||||
passwordEdit->setPlaceholderText(QString());
|
||||
storedVerifier.clear();
|
||||
savedVerifier.clear();
|
||||
saveEdit->clear();
|
||||
saveEdit->setPlaceholderText(tr("Unique Server Name"));
|
||||
saveEdit->setDisabled(false);
|
||||
|
|
@ -346,9 +355,19 @@ void DlgConnect::passwordSaved(QT_STATE_CHANGED_T state)
|
|||
Q_UNUSED(state);
|
||||
if (savePasswordCheckBox->isChecked()) {
|
||||
autoConnectCheckBox->setEnabled(true);
|
||||
// Re-ticking keeps a password the user has started typing; otherwise restore the saved
|
||||
// login this dialog was opened with, so unticking and changing your mind is harmless.
|
||||
if (passwordEdit->text().isEmpty()) {
|
||||
storedVerifier = savedVerifier;
|
||||
passwordEdit->setPlaceholderText(savedVerifier.isEmpty() ? QString() : tr("Password saved"));
|
||||
}
|
||||
} else {
|
||||
autoConnectCheckBox->setChecked(false);
|
||||
autoConnectCheckBox->setEnabled(false);
|
||||
// Unticking means the password typed below is sent for this connection: stop sending the
|
||||
// saved verifier instead, and stop claiming a password is saved.
|
||||
storedVerifier.clear();
|
||||
passwordEdit->setPlaceholderText(QString());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -356,10 +375,11 @@ void DlgConnect::actOk()
|
|||
{
|
||||
ServersSettings &servers = SettingsCache::instance().servers();
|
||||
|
||||
// Never write a newly typed plaintext password to disk when a verifier is already stored:
|
||||
// the typed value is used for this connection and a fresh verifier is persisted after a
|
||||
// successful login. Without a stored verifier we keep the previous (plaintext legacy) behavior.
|
||||
const QString passwordToSave = storedVerifier.isEmpty() ? passwordEdit->text() : storedVerifier;
|
||||
// Never write a newly typed plaintext password to disk when a verifier is available: the
|
||||
// typed value is used for this connection and a fresh verifier is persisted after a
|
||||
// successful login. Without any verifier we keep the previous (plaintext legacy) behavior.
|
||||
const QString verifierToSave = storedVerifier.isEmpty() ? savedVerifier : storedVerifier;
|
||||
const QString passwordToSave = verifierToSave.isEmpty() ? passwordEdit->text() : verifierToSave;
|
||||
|
||||
if (newHostButton->isChecked()) {
|
||||
if (saveEdit->text().isEmpty()) {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ private:
|
|||
QPushButton *btnConnect, *btnForgotPassword, *btnRefreshServers, *btnDeleteServer;
|
||||
QMap<QString, std::pair<QString, UserConnection_Information>> savedHostList;
|
||||
HandlePublicServers *hps;
|
||||
//! \brief Saved "$scrypt$..." verifier as loaded for the selected profile, kept so that
|
||||
//! re-ticking "Save password" after unticking restores it.
|
||||
QString savedVerifier;
|
||||
//! \brief Verifier in effect for this connection: empty while "Save password" is unticked.
|
||||
QString storedVerifier;
|
||||
const QString placeHolderText = tr("Downloading...");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ class QWidget;
|
|||
|
||||
DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nullptr)
|
||||
{
|
||||
setObjectName("editTokensDialog");
|
||||
nameLabel = new QLabel(tr("&Name:"));
|
||||
nameEdit = new QLineEdit;
|
||||
nameEdit->setMaxLength(MAX_NAME_LENGTH);
|
||||
|
|
|
|||
|
|
@ -28,12 +28,16 @@ class QWidget;
|
|||
*/
|
||||
AbstractDlgDeckTextEdit::AbstractDlgDeckTextEdit(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setObjectName("dlgDeckTextEdit");
|
||||
|
||||
contentsEdit = new QPlainTextEdit;
|
||||
contentsEdit->setObjectName("contentsEdit");
|
||||
|
||||
refreshButton = new QPushButton(tr("&Refresh"));
|
||||
connect(refreshButton, &QPushButton::clicked, this, &AbstractDlgDeckTextEdit::actRefresh);
|
||||
|
||||
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||
buttonBox->setObjectName("buttonBox");
|
||||
buttonBox->addButton(refreshButton, QDialogButtonBox::ActionRole);
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &AbstractDlgDeckTextEdit::actOK);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, &AbstractDlgDeckTextEdit::reject);
|
||||
|
|
|
|||
|
|
@ -16,8 +16,11 @@ class QWidget;
|
|||
|
||||
DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setObjectName("localGameDialog");
|
||||
|
||||
numberPlayersLabel = new QLabel(tr("Players:"), this);
|
||||
numberPlayersEdit = new QSpinBox(this);
|
||||
numberPlayersEdit->setObjectName("numberPlayersEdit");
|
||||
numberPlayersEdit->setMinimum(1);
|
||||
numberPlayersEdit->setMaximum(8);
|
||||
numberPlayersEdit->setValue(1);
|
||||
|
|
@ -28,10 +31,12 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent)
|
|||
generalGrid->addWidget(numberPlayersLabel, 0, 0);
|
||||
generalGrid->addWidget(numberPlayersEdit, 0, 1);
|
||||
generalGroupBox = new QGroupBox(tr("General"), this);
|
||||
generalGroupBox->setObjectName("generalGroupBox");
|
||||
generalGroupBox->setLayout(generalGrid);
|
||||
|
||||
startingLifeTotalLabel = new QLabel(tr("Starting life total:"), this);
|
||||
startingLifeTotalEdit = new QSpinBox(this);
|
||||
startingLifeTotalEdit->setObjectName("startingLifeTotalEdit");
|
||||
startingLifeTotalEdit->setMinimum(-999999999);
|
||||
startingLifeTotalEdit->setMaximum(999999999);
|
||||
startingLifeTotalEdit->setValue(20);
|
||||
|
|
@ -42,11 +47,14 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent)
|
|||
gameSetupGrid->addWidget(startingLifeTotalLabel, 0, 0);
|
||||
gameSetupGrid->addWidget(startingLifeTotalEdit, 0, 1);
|
||||
gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options"), this);
|
||||
gameSetupOptionsGroupBox->setObjectName("gameSetupOptionsGroupBox");
|
||||
gameSetupOptionsGroupBox->setLayout(gameSetupGrid);
|
||||
|
||||
rememberSettingsCheckBox = new QCheckBox(tr("Remember settings"), this);
|
||||
rememberSettingsCheckBox->setObjectName("rememberSettingsCheckBox");
|
||||
|
||||
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
buttonBox->setObjectName("buttonBox");
|
||||
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgLocalGameOptions::actOK);
|
||||
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgLocalGameOptions::reject);
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
|
||||
WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
|
||||
{
|
||||
setObjectName("manageSetsWindow");
|
||||
setOrderIsSorted = false;
|
||||
|
||||
// left toolbar
|
||||
|
|
@ -221,6 +222,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
|
|||
|
||||
WndSets::~WndSets()
|
||||
{
|
||||
disconnect(view->header(), &QHeaderView::geometriesChanged, this, &WndSets::saveHeaderState);
|
||||
}
|
||||
|
||||
void WndSets::closeEvent(QCloseEvent * /*ev*/)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
#include "dlg_my_reports.h"
|
||||
|
||||
#include "../interface/theme_manager.h"
|
||||
#include "../utility/report_utils.h"
|
||||
#include "abstract_client.h"
|
||||
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||
#include "libcockatrice/protocol/pb/serverinfo_report.pb.h"
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QFont>
|
||||
#include <QGroupBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
|
|
@ -24,7 +26,6 @@
|
|||
#include <libcockatrice/protocol/pb/response_report_details.pb.h>
|
||||
#include <libcockatrice/protocol/pb/response_report_my_list.pb.h>
|
||||
#include <libcockatrice/protocol/pending_command.h>
|
||||
#include <libcockatrice/utility/qt_utils.h>
|
||||
#include <qnamespace.h>
|
||||
#include <string>
|
||||
|
||||
|
|
@ -69,7 +70,8 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent)
|
|||
auto *chatGroup = new QGroupBox(tr("Chat Log Context"));
|
||||
chatLogEdit = new QTextEdit;
|
||||
chatLogEdit->setReadOnly(true);
|
||||
chatLogEdit->setFont(QtUtils::monospaceFont());
|
||||
applyChatLogFont();
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, &DlgMyReports::applyChatLogFont);
|
||||
auto *chatLayout = new QVBoxLayout(chatGroup);
|
||||
chatLayout->setContentsMargins(4, 4, 4, 4);
|
||||
chatLayout->addWidget(chatLogEdit);
|
||||
|
|
@ -122,6 +124,11 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent)
|
|||
refreshList();
|
||||
}
|
||||
|
||||
void DlgMyReports::applyChatLogFont()
|
||||
{
|
||||
chatLogEdit->setFont(report_utils::reportLogFont());
|
||||
}
|
||||
|
||||
void DlgMyReports::refreshList()
|
||||
{
|
||||
selectedReportIdBeforeRefresh = selectedReportId;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ private slots:
|
|||
void reportDetailsResponse(const Response &response);
|
||||
void addComment();
|
||||
void addCommentResponse(const Response &response);
|
||||
void applyChatLogFont();
|
||||
|
||||
private:
|
||||
void loadReportDetails(const ServerInfo_Report &report);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
#include "dlg_report_user.h"
|
||||
|
||||
#include "../interface/theme_manager.h"
|
||||
#include "../utility/report_utils.h"
|
||||
#include "abstract_client.h"
|
||||
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFont>
|
||||
#include <QGridLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QLabel>
|
||||
|
|
@ -91,7 +94,8 @@ DlgReportUser::DlgReportUser(AbstractClient *_client,
|
|||
auto *chatLayout = new QVBoxLayout(chatGroup);
|
||||
chatLogEdit = new QTextEdit;
|
||||
chatLogEdit->setReadOnly(true);
|
||||
chatLogEdit->setFont(QtUtils::monospaceFont());
|
||||
applyChatLogFont();
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, &DlgReportUser::applyChatLogFont);
|
||||
if (!_autoChatLog.isEmpty()) {
|
||||
chatLogEdit->setPlainText(_autoChatLog);
|
||||
} else {
|
||||
|
|
@ -120,6 +124,11 @@ DlgReportUser::DlgReportUser(AbstractClient *_client,
|
|||
layout->addWidget(buttonBox);
|
||||
}
|
||||
|
||||
void DlgReportUser::applyChatLogFont()
|
||||
{
|
||||
chatLogEdit->setFont(report_utils::reportLogFont());
|
||||
}
|
||||
|
||||
void DlgReportUser::actSubmit()
|
||||
{
|
||||
const QString description = descriptionEdit->toPlainText().trimmed();
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ public:
|
|||
private slots:
|
||||
void actSubmit();
|
||||
void reportResponse(const Response &response);
|
||||
void applyChatLogFont();
|
||||
|
||||
private:
|
||||
static QString categoryLabel(const QString &key);
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
|
|||
resize(calculateDialogSize(hostSize, available.size(), minimum));
|
||||
setSizeGripEnabled(true);
|
||||
|
||||
setObjectName("settingsDialog");
|
||||
|
||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
|
||||
|
||||
settingsView = new SettingsView(this);
|
||||
|
|
@ -82,6 +84,7 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
|
|||
buttonBox->addStretch();
|
||||
okButton = new QPushButton;
|
||||
okButton->setDefault(true);
|
||||
okButton->setObjectName("settingsOkButton");
|
||||
connect(okButton, &QPushButton::clicked, this, &DlgSettings::close);
|
||||
buttonBox->addWidget(okButton);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
#include "../deck_share/shared_deck_preview_widget.h"
|
||||
#include "../general/layout_containers/flow_widget.h"
|
||||
#include "../interface/theme_manager.h"
|
||||
|
||||
#include <QAbstractButton>
|
||||
#include <QDateTime>
|
||||
|
|
@ -35,9 +36,14 @@ DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent,
|
|||
// QLabels render AutoText and markup would otherwise be shown as rich text.
|
||||
auto *titleLabel =
|
||||
new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this);
|
||||
QFont titleFont = QtUtils::boldFont(titleLabel->font());
|
||||
titleFont.setPointSize(titleFont.pointSize() + 2);
|
||||
titleLabel->setFont(titleFont);
|
||||
// Family comes from the InterfaceDefault theme role; the +2 size and bold stay caller-side.
|
||||
auto applyTitleFont = [titleLabel]() {
|
||||
QFont titleFont = QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault));
|
||||
titleFont.setPointSize(titleFont.pointSize() + 2);
|
||||
titleLabel->setFont(titleFont);
|
||||
};
|
||||
applyTitleFont();
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, applyTitleFont);
|
||||
mainLayout->addWidget(titleLabel);
|
||||
|
||||
if (!serverText.isEmpty()) {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
|
||||
DlgTipOfTheDay::DlgTipOfTheDay(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setObjectName("tipOfTheDayDialog");
|
||||
successfulInit = false;
|
||||
QString xmlPath = "theme:tips/tips_of_the_day.xml";
|
||||
tipDatabase = new TipsOfTheDay(xmlPath, this);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ static const QString UPDATE_INSTALLER_OVERRIDE = "Cockatrice-Update-Override.exe
|
|||
|
||||
DlgUpdate::DlgUpdate(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setObjectName("updateDialog");
|
||||
|
||||
// Handle layout
|
||||
statusLabel = new QLabel(this);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class QWidget;
|
|||
|
||||
DlgViewLog::DlgViewLog(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setObjectName("viewLogDialog");
|
||||
logArea = new QPlainTextEdit;
|
||||
logArea->setReadOnly(true);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../../pixel_map_generator.h"
|
||||
#include "../interface/theme_manager.h"
|
||||
#include "../settings_page/abstract_settings_page.h"
|
||||
#include "../settings_page/appearance_settings_page.h"
|
||||
#include "../settings_page/deck_editor_settings_page.h"
|
||||
|
|
@ -119,6 +120,8 @@ QWidget *fromWidgetId(WidgetId id)
|
|||
|
||||
SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false)
|
||||
{
|
||||
setObjectName("settingsView");
|
||||
|
||||
setupUi();
|
||||
|
||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi);
|
||||
|
|
@ -135,12 +138,14 @@ void SettingsView::setupUi()
|
|||
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
|
||||
searchEdit->installEventFilter(this);
|
||||
connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged);
|
||||
searchEdit->setObjectName("settingsSearchEdit");
|
||||
|
||||
auto *searchLayout = new QHBoxLayout;
|
||||
searchLayout->addWidget(searchEdit);
|
||||
|
||||
// Pages stacked widget
|
||||
pagesWidget = new QStackedWidget;
|
||||
pagesWidget->setObjectName("pagesWidget");
|
||||
|
||||
auto *generalPage = new GeneralSettingsPage;
|
||||
auto *appearancePage = new AppearanceSettingsPage;
|
||||
|
|
@ -199,12 +204,17 @@ void SettingsView::setupUi()
|
|||
searchDelegate->setPageIcons(icons);
|
||||
searchResultsView->viewport()->update();
|
||||
});
|
||||
// The search delegate reads the InterfaceDefault role at paint time, so a
|
||||
// font change only needs a repaint, mirroring the theme change above.
|
||||
connect(themeManager, &ThemeManager::fontsChanged, searchResultsView,
|
||||
[this] { searchResultsView->viewport()->update(); });
|
||||
|
||||
// Build search index after pages are created
|
||||
buildSearchIndex();
|
||||
|
||||
// Pages container (stacked widget + search results overlay)
|
||||
pagesContainer = new QWidget;
|
||||
pagesContainer->setObjectName("pagesContainer");
|
||||
auto *containerLayout = new QStackedLayout;
|
||||
containerLayout->setStackingMode(QStackedLayout::StackAll);
|
||||
containerLayout->addWidget(pagesWidget);
|
||||
|
|
@ -244,6 +254,7 @@ void SettingsView::setupUi()
|
|||
void SettingsView::setupNavTree()
|
||||
{
|
||||
navTree = new QTreeWidget;
|
||||
navTree->setObjectName("navTree");
|
||||
navTree->setHeaderHidden(true);
|
||||
navTree->setRootIsDecorated(true);
|
||||
navTree->setIconSize(QSize(24, 24));
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
#include "banner_widget.h"
|
||||
|
||||
#include "../../../pixel_map_generator.h"
|
||||
#include "../../../theme_manager.h"
|
||||
|
||||
#include <QBrush>
|
||||
#include <QColor>
|
||||
#include <QFont>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLinearGradient>
|
||||
#include <QPainter>
|
||||
|
|
@ -28,7 +30,9 @@ BannerWidget::BannerWidget(QWidget *parent, const QString &text, Qt::Orientation
|
|||
if (transparency > 50) {
|
||||
textColor = " color: white;";
|
||||
}
|
||||
bannerLabel->setStyleSheet("font-size: 24px; font-weight: bold;" + textColor);
|
||||
bannerLabel->setStyleSheet(textColor);
|
||||
applyInterfaceDefaultFont();
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, &BannerWidget::applyInterfaceDefaultFont);
|
||||
|
||||
layout->addWidget(iconLabel);
|
||||
layout->addWidget(bannerLabel);
|
||||
|
|
@ -56,6 +60,16 @@ void BannerWidget::setText(const QString &text) const
|
|||
bannerLabel->setText(text);
|
||||
}
|
||||
|
||||
void BannerWidget::applyInterfaceDefaultFont()
|
||||
{
|
||||
// Family comes from the InterfaceDefault theme role; the 24px size and
|
||||
// bold weight previously carried by the stylesheet stay caller-side.
|
||||
QFont bannerFont = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
bannerFont.setPixelSize(24);
|
||||
bannerFont.setWeight(QFont::Bold);
|
||||
bannerLabel->setFont(bannerFont);
|
||||
}
|
||||
|
||||
void BannerWidget::setClickable(bool _clickable)
|
||||
{
|
||||
clickable = _clickable;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ signals:
|
|||
private slots:
|
||||
void toggleBuddyVisibility() const;
|
||||
void updateDropdownIconState() const;
|
||||
void applyInterfaceDefaultFont();
|
||||
};
|
||||
|
||||
#endif // BANNER_WIDGET_H
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#include "bar_chart_widget.h"
|
||||
|
||||
#include "../../interface/theme_manager.h"
|
||||
|
||||
#include <QBrush>
|
||||
#include <QFlags>
|
||||
#include <QFont>
|
||||
|
|
@ -19,6 +21,10 @@
|
|||
BarChartWidget::BarChartWidget(QWidget *parent) : QWidget(parent)
|
||||
{
|
||||
setMouseTracking(true);
|
||||
|
||||
// Labels read the InterfaceDefault role at paint time, so a font change
|
||||
// only needs a repaint.
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); });
|
||||
}
|
||||
|
||||
void BarChartWidget::setBars(const QVector<BarData> &newBars)
|
||||
|
|
@ -146,7 +152,8 @@ void BarChartWidget::paintEvent(QPaintEvent *)
|
|||
|
||||
// draw label below bar
|
||||
QRect labelRect(left + i * (barWidth + spacing), bottom, barWidth, LABEL_H);
|
||||
QFont f = p.font();
|
||||
// Family comes from the InterfaceDefault theme role; bold stays caller-side.
|
||||
QFont f = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
f.setBold(true);
|
||||
p.setFont(f);
|
||||
p.setPen(Qt::black);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#include "segmented_bar_widget.h"
|
||||
|
||||
#include "../../interface/theme_manager.h"
|
||||
|
||||
#include <QBrush>
|
||||
#include <QFont>
|
||||
#include <QLinearGradient>
|
||||
|
|
@ -22,6 +24,10 @@ SegmentedBarWidget::SegmentedBarWidget(QString label, QVector<Segment> segments,
|
|||
setMinimumWidth(36);
|
||||
setMaximumWidth(50);
|
||||
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
|
||||
|
||||
// The label reads the InterfaceDefault role at paint time, so a font
|
||||
// change only needs a repaint.
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); });
|
||||
}
|
||||
|
||||
QSize SegmentedBarWidget::sizeHint() const
|
||||
|
|
@ -95,7 +101,8 @@ void SegmentedBarWidget::paintEvent(QPaintEvent *)
|
|||
|
||||
// draw label
|
||||
QRect labelRect(0, h - LABEL_H, w, LABEL_H);
|
||||
QFont f = p.font();
|
||||
// Family comes from the InterfaceDefault theme role; bold stays caller-side.
|
||||
QFont f = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
f.setBold(true);
|
||||
p.setFont(f);
|
||||
p.setPen(Qt::black);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
#include "dynamic_font_size_label.h"
|
||||
|
||||
#include "../../interface/theme_manager.h"
|
||||
|
||||
#include <QFont>
|
||||
#include <QFontMetricsF>
|
||||
#include <QObject>
|
||||
|
|
@ -28,9 +30,13 @@ void DynamicFontSizeLabel::paintEvent(QPaintEvent *event)
|
|||
// QElapsedTimer timer;
|
||||
// timer.start();
|
||||
|
||||
QFont newFont = font();
|
||||
// Family follows the InterfaceDefault theme role; the fitted point size
|
||||
// stays computed from the widget geometry as before.
|
||||
QFont newFont = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
float fontSize = getWidgetMaximumFontSize(this, this->text());
|
||||
newFont.setPointSizeF(fontSize);
|
||||
// Preserve the caller's bold emphasis, which the role font does not carry.
|
||||
newFont.setBold(font().bold());
|
||||
setFont(newFont);
|
||||
// qDebug() << "Font size set to" << fontSize;
|
||||
|
||||
|
|
@ -40,7 +46,10 @@ void DynamicFontSizeLabel::paintEvent(QPaintEvent *event)
|
|||
|
||||
float DynamicFontSizeLabel::getWidgetMaximumFontSize(QWidget *widget, const QString &text)
|
||||
{
|
||||
QFont font = widget->font();
|
||||
// Measure with the InterfaceDefault family so the fit matches the painted font.
|
||||
QFont font = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
font.setBold(widget->font().bold());
|
||||
font.setPointSizeF(widget->font().pointSizeF());
|
||||
const QRect widgetRect = widget->contentsRect();
|
||||
const float widgetWidth = widgetRect.width();
|
||||
const float widgetHeight = widgetRect.height();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "dynamic_font_size_push_button.h"
|
||||
|
||||
#include "../../interface/theme_manager.h"
|
||||
#include "dynamic_font_size_label.h"
|
||||
|
||||
#include <QBrush>
|
||||
|
|
@ -20,10 +21,12 @@ void DynamicFontSizePushButton::paintEvent(QPaintEvent *event)
|
|||
// Call the base class paintEvent to preserve any other painting behavior
|
||||
QPushButton::paintEvent(event);
|
||||
|
||||
// Adjust the font size dynamically based on the text
|
||||
QFont newFont = font();
|
||||
// Adjust the font size dynamically based on the text. Family follows the
|
||||
// InterfaceDefault theme role; the fitted size stays computed as before.
|
||||
QFont newFont = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
float fontSize = DynamicFontSizeLabel::getWidgetMaximumFontSize(this, this->text());
|
||||
newFont.setPointSizeF(fontSize);
|
||||
newFont.setBold(font().bold());
|
||||
setFont(newFont);
|
||||
|
||||
// Get painter for custom painting
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
#include "home_styled_button.h"
|
||||
|
||||
#include "../../theme_manager.h"
|
||||
|
||||
#include <QBrush>
|
||||
#include <QFont>
|
||||
#include <QFontMetrics>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
|
|
@ -21,6 +24,18 @@ HomeStyledButton::HomeStyledButton(const QString &text, QPair<QColor, QColor> _g
|
|||
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
setMinimumHeight(25);
|
||||
setStyleSheet(generateButtonStylesheet(gradientColors));
|
||||
applyInterfaceDefaultFont();
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, &HomeStyledButton::applyInterfaceDefaultFont);
|
||||
}
|
||||
|
||||
void HomeStyledButton::applyInterfaceDefaultFont()
|
||||
{
|
||||
// Family comes from the InterfaceDefault theme role; the 34px size and
|
||||
// bold weight previously carried by the stylesheet stay caller-side.
|
||||
QFont buttonFont = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
buttonFont.setPixelSize(34);
|
||||
buttonFont.setWeight(QFont::Bold);
|
||||
setFont(buttonFont);
|
||||
}
|
||||
|
||||
void HomeStyledButton::updateStylesheet(const QPair<QColor, QColor> &colors)
|
||||
|
|
@ -50,9 +65,6 @@ QString HomeStyledButton::generateButtonStylesheet(const QPair<QColor, QColor> &
|
|||
|
||||
return QString(R"(
|
||||
QPushButton {
|
||||
font-size: 34px;
|
||||
font-weight: bold;
|
||||
font-family: sans-serif, "Segoe UI", "Helvetica Neue";
|
||||
padding: 30px;
|
||||
color: white;
|
||||
border: 2px solid %1;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ public:
|
|||
public slots:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void applyInterfaceDefaultFont();
|
||||
|
||||
private:
|
||||
QPair<QColor, QColor> gradientColors;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
|
||||
: QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home")))
|
||||
{
|
||||
setObjectName("homeWidget");
|
||||
layout = new QGridLayout(this);
|
||||
|
||||
backgroundSourceCard = new CardInfoPictureArtCropWidget(this);
|
||||
|
|
@ -287,9 +288,9 @@ void HomeWidget::updateButtonsToBackgroundColor()
|
|||
QGroupBox *HomeWidget::createButtons()
|
||||
{
|
||||
QGroupBox *box = new QGroupBox(this);
|
||||
box->setObjectName("homeButtonsBox");
|
||||
box->setStyleSheet(R"(
|
||||
QGroupBox {
|
||||
font-size: 20px;
|
||||
color: white; /* Title text color */
|
||||
background: transparent;
|
||||
}
|
||||
|
|
@ -300,6 +301,14 @@ QGroupBox *HomeWidget::createButtons()
|
|||
subcontrol-position: top center; /* or top left / right */
|
||||
}
|
||||
)");
|
||||
// Family comes from the InterfaceDefault theme role; the 20px size stays caller-side.
|
||||
auto applyBoxFont = [box]() {
|
||||
QFont boxFont = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
boxFont.setPixelSize(20);
|
||||
box->setFont(boxFont);
|
||||
};
|
||||
applyBoxFont();
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, applyBoxFont);
|
||||
box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
QVBoxLayout *boxLayout = new QVBoxLayout;
|
||||
boxLayout->setAlignment(Qt::AlignHCenter);
|
||||
|
|
@ -311,27 +320,34 @@ QGroupBox *HomeWidget::createButtons()
|
|||
boxLayout->addSpacing(25);
|
||||
|
||||
connectButton = new HomeStyledButton(QString(), gradientColors);
|
||||
connectButton->setObjectName("homeConnectButton");
|
||||
boxLayout->addWidget(connectButton);
|
||||
|
||||
visualDeckEditorButton = new HomeStyledButton(QString(), gradientColors);
|
||||
visualDeckEditorButton->setObjectName("homeCreateNewDeckButton");
|
||||
connect(visualDeckEditorButton, &QPushButton::clicked, tabSupervisor,
|
||||
[this] { tabSupervisor->openDeckInNewTab(LoadedDeck()); });
|
||||
boxLayout->addWidget(visualDeckEditorButton);
|
||||
visualDeckStorageButton = new HomeStyledButton(QString(), gradientColors);
|
||||
visualDeckStorageButton->setObjectName("homeBrowseDecksButton");
|
||||
connect(visualDeckStorageButton, &QPushButton::clicked, tabSupervisor,
|
||||
[this] { tabSupervisor->actTabVisualDeckStorage(true); });
|
||||
boxLayout->addWidget(visualDeckStorageButton);
|
||||
visualDatabaseDisplayButton = new HomeStyledButton(QString(), gradientColors);
|
||||
visualDatabaseDisplayButton->setObjectName("homeBrowseCardDatabaseButton");
|
||||
connect(visualDatabaseDisplayButton, &QPushButton::clicked, tabSupervisor,
|
||||
&TabSupervisor::addVisualDatabaseDisplayTab);
|
||||
boxLayout->addWidget(visualDatabaseDisplayButton);
|
||||
edhrecButton = new HomeStyledButton(QString(), gradientColors);
|
||||
edhrecButton->setObjectName("homeBrowseEdhrecButton");
|
||||
connect(edhrecButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addEdhrecMainTab);
|
||||
boxLayout->addWidget(edhrecButton);
|
||||
archidektButton = new HomeStyledButton(QString(), gradientColors);
|
||||
archidektButton->setObjectName("homeBrowseArchidektButton");
|
||||
connect(archidektButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addArchidektTab);
|
||||
boxLayout->addWidget(archidektButton);
|
||||
replayButton = new HomeStyledButton(QString(), gradientColors);
|
||||
replayButton->setObjectName("homeViewReplaysButton");
|
||||
connect(replayButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabReplays(true); });
|
||||
boxLayout->addWidget(replayButton);
|
||||
if (qobject_cast<MainWindow *>(tabSupervisor->parentWidget())) {
|
||||
|
|
@ -463,7 +479,9 @@ void HomeWidget::paintEvent(QPaintEvent *event)
|
|||
constexpr int margin = 15;
|
||||
constexpr qreal attributionMargin = 4.0;
|
||||
|
||||
QFont attributionFont = painter.font();
|
||||
// Family comes from the InterfaceDefault theme role; the 14px sizes and
|
||||
// bold weights stay caller-side as before.
|
||||
QFont attributionFont = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
attributionFont.setPointSize(14);
|
||||
attributionFont.setBold(true);
|
||||
painter.setFont(attributionFont);
|
||||
|
|
@ -476,7 +494,7 @@ void HomeWidget::paintEvent(QPaintEvent *event)
|
|||
|
||||
// Card name bubble above the attribution (when enabled).
|
||||
if (!cardName.isEmpty() && SettingsCache::instance().appearance().getHomeTabDisplayCardName()) {
|
||||
QFont font = painter.font();
|
||||
QFont font = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
font.setPointSize(14);
|
||||
font.setBold(true);
|
||||
painter.setFont(font);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "first_run_wizard.h"
|
||||
|
||||
#include "../interface/theme_manager.h"
|
||||
#include "first_run_wizard_page.h"
|
||||
#include "pages/account_setup_page.h"
|
||||
#include "pages/card_database_setup_page.h"
|
||||
|
|
@ -32,9 +33,8 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent)
|
|||
bannerHost = new BannerHost(this);
|
||||
|
||||
titleLabel = new QLabel(this);
|
||||
QFont titleFont = QtUtils::boldFont(titleLabel->font());
|
||||
titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4);
|
||||
titleLabel->setFont(titleFont);
|
||||
applyInterfaceDefaultFont();
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, &FirstRunWizard::applyInterfaceDefaultFont);
|
||||
|
||||
subtitleLabel = new QLabel(this);
|
||||
subtitleLabel->setWordWrap(true);
|
||||
|
|
@ -154,6 +154,16 @@ void FirstRunWizard::updateChrome()
|
|||
}
|
||||
}
|
||||
|
||||
void FirstRunWizard::applyInterfaceDefaultFont()
|
||||
{
|
||||
// Family comes from the InterfaceDefault theme role; the *1.4 size
|
||||
// multiplier and bold weight stay caller-side as before.
|
||||
QFont titleFont = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4);
|
||||
titleFont.setBold(true);
|
||||
titleLabel->setFont(titleFont);
|
||||
}
|
||||
|
||||
void FirstRunWizard::goNext()
|
||||
{
|
||||
FirstRunWizardPage *page = pages[currentIndex];
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ private slots:
|
|||
void goBack();
|
||||
void skip();
|
||||
void updateChrome();
|
||||
void applyInterfaceDefaultFont();
|
||||
|
||||
private:
|
||||
void addPage(FirstRunWizardPage *page);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "all_zones_card_amount_widget.h"
|
||||
|
||||
#include "../../theme_manager.h"
|
||||
#include "../general/display/shadow_background_label.h"
|
||||
#include "card_amount_widget.h"
|
||||
#include "libcockatrice/card/card_info.h"
|
||||
|
|
@ -67,6 +68,7 @@ AllZonesCardAmountWidget::AllZonesCardAmountWidget(QWidget *parent,
|
|||
buttonBoxSideboard->setVisible(!isToken);
|
||||
|
||||
connect(cardSizeSlider, &QSlider::valueChanged, this, &AllZonesCardAmountWidget::adjustFontSize);
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, &AllZonesCardAmountWidget::applyCardTextFont);
|
||||
|
||||
QTimer::singleShot(10, this, [this]() { adjustFontSize(this->cardSizeSlider->value()); });
|
||||
|
||||
|
|
@ -90,8 +92,9 @@ void AllZonesCardAmountWidget::adjustFontSize(int scalePercentage)
|
|||
int newFontSize = minFontSize + (scalePercentage - basePercentage) * (maxFontSize - minFontSize) / 225;
|
||||
newFontSize = std::clamp(newFontSize, minFontSize, maxFontSize);
|
||||
|
||||
// Update the font labels
|
||||
QFont zoneLabelFont = zoneLabelMainboard->font();
|
||||
// Update the font labels. The family comes from the CardText theme
|
||||
// role; the slider-driven point size stays as before.
|
||||
QFont zoneLabelFont = themeManager->appFont(AppFont::CardText);
|
||||
zoneLabelFont.setPointSize(newFontSize);
|
||||
zoneLabelMainboard->setFont(zoneLabelFont);
|
||||
zoneLabelSideboard->setFont(zoneLabelFont);
|
||||
|
|
@ -101,6 +104,11 @@ void AllZonesCardAmountWidget::adjustFontSize(int scalePercentage)
|
|||
repaint();
|
||||
}
|
||||
|
||||
void AllZonesCardAmountWidget::applyCardTextFont()
|
||||
{
|
||||
adjustFontSize(cardSizeSlider->value());
|
||||
}
|
||||
|
||||
void AllZonesCardAmountWidget::setAmounts(int mainboardAmount, int sideboardAmount, int tokensboardAmount)
|
||||
{
|
||||
buttonBoxMainboard->setAmount(mainboardAmount);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ public slots:
|
|||
void adjustFontSize(int scalePercentage);
|
||||
void setAmounts(int mainboardAmount, int sideboardAmount, int tokensboardAmount);
|
||||
|
||||
private slots:
|
||||
void applyCardTextFont();
|
||||
|
||||
private:
|
||||
QVBoxLayout *layout;
|
||||
QSlider *cardSizeSlider;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "card_amount_widget.h"
|
||||
|
||||
#include "../../theme_manager.h"
|
||||
#include "../deck_editor/deck_state_manager.h"
|
||||
#include "../general/display/dynamic_font_size_push_button.h"
|
||||
#include "deck_list_model.h"
|
||||
|
|
@ -77,6 +78,7 @@ CardAmountWidget::CardAmountWidget(QWidget *parent,
|
|||
|
||||
// Connect slider for dynamic font size adjustment
|
||||
connect(cardSizeSlider, &QSlider::valueChanged, this, &CardAmountWidget::adjustFontSize);
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, &CardAmountWidget::applyCardTextFont);
|
||||
}
|
||||
|
||||
int CardAmountWidget::getAmount()
|
||||
|
|
@ -138,8 +140,9 @@ void CardAmountWidget::adjustFontSize(int scalePercentage)
|
|||
int newFontSize = minFontSize + (scalePercentage - basePercentage) * (maxFontSize - minFontSize) / 225;
|
||||
newFontSize = std::clamp(newFontSize, minFontSize, maxFontSize);
|
||||
|
||||
// Update the font for card count label
|
||||
QFont cardCountFont = cardCountInZone->font();
|
||||
// Update the font for card count label. The family comes from the
|
||||
// CardText theme role; the slider-driven point size stays as before.
|
||||
QFont cardCountFont = themeManager->appFont(AppFont::CardText);
|
||||
cardCountFont.setPointSize(newFontSize);
|
||||
cardCountInZone->setFont(cardCountFont);
|
||||
|
||||
|
|
@ -150,6 +153,11 @@ void CardAmountWidget::adjustFontSize(int scalePercentage)
|
|||
repaint();
|
||||
}
|
||||
|
||||
void CardAmountWidget::applyCardTextFont()
|
||||
{
|
||||
adjustFontSize(cardSizeSlider->value());
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Updates the card count display in the widget.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ private slots:
|
|||
void removePrintingSideboard();
|
||||
void removePrintingTokensboard();
|
||||
void adjustFontSize(int scalePercentage);
|
||||
void applyCardTextFont();
|
||||
};
|
||||
|
||||
#endif // CARD_AMOUNT_WIDGET_H
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
#include "printing_selector_placeholder_widget.h"
|
||||
|
||||
#include "../interface/theme_manager.h"
|
||||
|
||||
#include <QFont>
|
||||
#include <QLabel>
|
||||
#include <QSizePolicy>
|
||||
#include <QVBoxLayout>
|
||||
|
|
@ -30,10 +33,12 @@ PrintingSelectorPlaceholderWidget::PrintingSelectorPlaceholderWidget(QWidget *pa
|
|||
textLabel->setStyleSheet(R"(
|
||||
QLabel {
|
||||
color: palette(mid);
|
||||
font-size: 14px;
|
||||
padding: 10px;
|
||||
}
|
||||
)");
|
||||
applyInterfaceDefaultFont();
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this,
|
||||
&PrintingSelectorPlaceholderWidget::applyInterfaceDefaultFont);
|
||||
|
||||
mainLayout->addWidget(imageLabel);
|
||||
mainLayout->addWidget(textLabel);
|
||||
|
|
@ -44,4 +49,12 @@ PrintingSelectorPlaceholderWidget::PrintingSelectorPlaceholderWidget(QWidget *pa
|
|||
void PrintingSelectorPlaceholderWidget::retranslateUi()
|
||||
{
|
||||
textLabel->setText(tr("Select a card to view its available printings"));
|
||||
}
|
||||
|
||||
void PrintingSelectorPlaceholderWidget::applyInterfaceDefaultFont()
|
||||
{
|
||||
// Family comes from the InterfaceDefault theme role; the 14px size stays caller-side.
|
||||
QFont placeholderFont = themeManager->appFont(AppFont::InterfaceDefault);
|
||||
placeholderFont.setPixelSize(14);
|
||||
textLabel->setFont(placeholderFont);
|
||||
}
|
||||
|
|
@ -14,6 +14,9 @@ class PrintingSelectorPlaceholderWidget : public QWidget
|
|||
public:
|
||||
explicit PrintingSelectorPlaceholderWidget(QWidget *parent = nullptr);
|
||||
|
||||
private slots:
|
||||
void applyInterfaceDefaultFont();
|
||||
|
||||
private:
|
||||
QVBoxLayout *mainLayout;
|
||||
QLabel *imageLabel;
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ ReplayManager::ReplayManager(QObject *parent, GameReplay *replay)
|
|||
|
||||
rewindBufferingTimer = new QTimer(this);
|
||||
rewindBufferingTimer->setSingleShot(true);
|
||||
connect(rewindBufferingTimer, &QTimer::timeout, this, &ReplayManager::processRewind);
|
||||
connect(rewindBufferingTimer, &QTimer::timeout, this, [this] { processRewind(); });
|
||||
}
|
||||
|
||||
ReplayManager::~ReplayManager()
|
||||
|
|
@ -65,7 +65,7 @@ ReplayManager::~ReplayManager()
|
|||
delete replay;
|
||||
}
|
||||
|
||||
void ReplayManager::skipToTime(int newTime, bool doRewindBuffering)
|
||||
void ReplayManager::skipToTime(int newTime, bool doRewindBuffering, bool includeEventsAtTime)
|
||||
{
|
||||
// check boundary conditions
|
||||
if (newTime < 0) {
|
||||
|
|
@ -75,15 +75,17 @@ void ReplayManager::skipToTime(int newTime, bool doRewindBuffering)
|
|||
newTime = maxTime;
|
||||
}
|
||||
|
||||
newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval
|
||||
if (!includeEventsAtTime) {
|
||||
newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval
|
||||
}
|
||||
|
||||
const bool isBackwardsSkip = newTime < currentProcessedTime;
|
||||
currentVisualTime = newTime;
|
||||
|
||||
if (isBackwardsSkip) {
|
||||
handleBackwardsSkip(doRewindBuffering);
|
||||
handleBackwardsSkip(doRewindBuffering, includeEventsAtTime);
|
||||
} else {
|
||||
processNewEvents(FORWARD_SKIP);
|
||||
processNewEvents(FORWARD_SKIP, includeEventsAtTime);
|
||||
}
|
||||
|
||||
timeChanged(currentVisualTime);
|
||||
|
|
@ -95,7 +97,7 @@ void ReplayManager::skipToTime(int newTime, bool doRewindBuffering)
|
|||
* @param doRewindBuffering When true, if multiple backward skips are made in quick succession, only a single rewind
|
||||
* is processed at the end. When false, the backwards skip will always cause an immediate rewind.
|
||||
*/
|
||||
void ReplayManager::handleBackwardsSkip(bool doRewindBuffering)
|
||||
void ReplayManager::handleBackwardsSkip(bool doRewindBuffering, bool includeEventsAtTime)
|
||||
{
|
||||
if (doRewindBuffering) {
|
||||
// We use a one-shot timer to implement the rewind buffering.
|
||||
|
|
@ -105,11 +107,11 @@ void ReplayManager::handleBackwardsSkip(bool doRewindBuffering)
|
|||
rewindBufferingTimer->start(SettingsCache::instance().userInterface().getRewindBufferingMs());
|
||||
} else {
|
||||
// otherwise, process the rewind immediately
|
||||
processRewind();
|
||||
processRewind(includeEventsAtTime);
|
||||
}
|
||||
}
|
||||
|
||||
void ReplayManager::processRewind()
|
||||
void ReplayManager::processRewind(bool includeEventsAtTime)
|
||||
{
|
||||
// stop any queued-up rewinds
|
||||
rewindBufferingTimer->stop();
|
||||
|
|
@ -117,7 +119,7 @@ void ReplayManager::processRewind()
|
|||
// process the rewind
|
||||
currentEvent = 0;
|
||||
emit rewound();
|
||||
processNewEvents(BACKWARD_SKIP);
|
||||
processNewEvents(BACKWARD_SKIP, includeEventsAtTime);
|
||||
}
|
||||
|
||||
void ReplayManager::replayTimerTimeout()
|
||||
|
|
@ -134,11 +136,13 @@ void ReplayManager::replayTimerTimeout()
|
|||
}
|
||||
|
||||
/** @brief Processes all unprocessed events up to the current time. */
|
||||
void ReplayManager::processNewEvents(PlaybackMode playbackMode)
|
||||
void ReplayManager::processNewEvents(PlaybackMode playbackMode, bool includeEventsAtTime)
|
||||
{
|
||||
currentProcessedTime = currentVisualTime;
|
||||
|
||||
while (currentEvent < replayTimeline.size() && replayTimeline[currentEvent] < currentProcessedTime) {
|
||||
while (currentEvent < replayTimeline.size() &&
|
||||
(replayTimeline[currentEvent] < currentProcessedTime ||
|
||||
(includeEventsAtTime && replayTimeline[currentEvent] == currentProcessedTime))) {
|
||||
EventProcessingOptions options;
|
||||
|
||||
// backwards skip => always skip reveal windows
|
||||
|
|
@ -236,6 +240,24 @@ void ReplayManager::setTime(int time)
|
|||
skipToTime(time, false);
|
||||
}
|
||||
|
||||
void ReplayManager::setTimeToEvent(int eventIndex)
|
||||
{
|
||||
if (eventIndex < 0 || eventIndex >= replayTimeline.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush a pending buffered rewind before seeking, otherwise its timer fires after this seek and rebuilds the
|
||||
// replay state from an outdated currentVisualTime, dropping the event we seek to. Stopping the timer instead
|
||||
// of processing it is not enough: playback ticks advance currentProcessedTime without rewinding currentEvent,
|
||||
// so the board could still be ahead of the target.
|
||||
if (rewindBufferingTimer->isActive()) {
|
||||
processRewind();
|
||||
}
|
||||
|
||||
// Unlike a timeline seek, jumping to an event should apply that event immediately.
|
||||
skipToTime(replayTimeline.at(eventIndex), false, true);
|
||||
}
|
||||
|
||||
void ReplayManager::skipByAmount(int amount)
|
||||
{
|
||||
skipToTime(currentVisualTime + amount, amount < 0);
|
||||
|
|
|
|||
|
|
@ -40,10 +40,10 @@ class ReplayManager : public QObject
|
|||
///< to rewind buffering
|
||||
int currentEvent = 0; ///< current event's index
|
||||
|
||||
void skipToTime(int newTime, bool doRewindBuffering);
|
||||
void handleBackwardsSkip(bool doRewindBuffering);
|
||||
void processRewind();
|
||||
void processNewEvents(PlaybackMode playbackMode);
|
||||
void skipToTime(int newTime, bool doRewindBuffering, bool includeEventsAtTime = false);
|
||||
void handleBackwardsSkip(bool doRewindBuffering, bool includeEventsAtTime = false);
|
||||
void processRewind(bool includeEventsAtTime = false);
|
||||
void processNewEvents(PlaybackMode playbackMode, bool includeEventsAtTime = false);
|
||||
void handleSkipEmptySection();
|
||||
|
||||
private slots:
|
||||
|
|
@ -73,6 +73,7 @@ public slots:
|
|||
void startReplay();
|
||||
void stopReplay();
|
||||
void setTime(int time);
|
||||
void setTimeToEvent(int eventIndex);
|
||||
void skipByAmount(int amount); // use a negative amount to skip backwards
|
||||
|
||||
signals:
|
||||
|
|
|
|||
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