Compare commits

...

26 commits

Author SHA1 Message Date
Lukas Brübach
e87cd80c9b [Fonts] Retire max font size setting into theme CardText min/max range
The per-card text size lives in the theme now, so a theme can pick the size
it was designed for instead of every user overriding it globally.

- CardText stores family + max point size; CardTextMinSize stores the
  overlay shrink-to-fit floor (omitted from fonts.toml when 1).
- ThemeManager::cardTextMaxSize()/cardTextMinSize() resolve the range with
  the retired defaults (12 max, 1 min), and cardTextMaxSize() falls back to
  the CardText role size that #7541 made live.
- The overlay widget starts at the explicit size when one is set (deck
  preview), else the theme max, and shrinks down to the theme floor.
- The appearance page's "Maximum font size" spin is gone; the range is
  edited in the theme's font grid, whose min/max spins now keep the saved
  range consistent with each other.
- Migration: appearance/maxFontSize is seeded into every user theme folder
  and into the active theme's folder (created when the theme is a shipped
  one that only exists in the system directory). The retired
  game/maxfontsize -> appearance/maxFontSize mapping stays, so users still
  on global.ini keep their value through the chain.
2026-11-04 07:58:17 +01:00
BruebachL
ed119cc940
[Fonts] Polish font editor accessibility and note text (#7542)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / vcpkg baseline (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 sanitize (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:57:55 +01:00
BruebachL
a791389c6a
[Fonts] Restore DeckList Serif default and live-update all role readers (#7541)
* [Fonts] Restore DeckList Serif default and live-update all role readers

* [Fonts] Keep the DeckList fallback on the application font

DeckList is read by the deck editor headers, the filter tree and the deck
view too, not only by the two surfaces that hardcoded Serif. Resolving the
unset role to Serif in appFont() made every user without a fonts.toml see
Serif outside the deck list.

- appFont(DeckList) falls back to the application font again.
- ThemeManager::deckListFont() resolves the role and keeps the Serif
  fallback for the two historical sites: the deck view zone headers and
  the printed deck list.
- DeckView no longer repaints its viewport on fontsChanged; touching the
  scene items already schedules the repaint in every attached view.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:57:55 +01:00
BruebachL
13e7a3baaf
[Fonts] Fix font preview leaks and reuse default-font loader (#7540)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:57:55 +01:00
BruebachL
3bcdfc3793
[Fonts] Migrate DeckList and InterfaceDefault surfaces to theme roles (#7539)
* [Fonts] Migrate DeckList and InterfaceDefault surfaces to theme roles

* [Fonts] Drop unused lambda captures in font-refresh handlers

* [Fonts] Fix pixel sizes and game overlay fonts from review

- Set the sizes the stylesheets carried with setPixelSize() rather than
  setPointSize(): home buttons (34), banner (24), home group box (20),
  the placeholder labels (14), the analytics drag handle (10), the EDHREC
  navigation buttons (24), the user info popup (11/14/11/10/11) and the
  game overlays (14/16/12). Points at 96 DPI were ~33% larger.
- The drag/total counters and tally columns keep the monospace family the
  stylesheets set, so digit widths stop changing as counts tick and
  white-space: pre stays aligned.
- applyDeckListFont() drops the cached tally rows before re-measuring, so
  previewing a larger DeckList font no longer leaves the tally clipped.
- Tally labels and the constructor/fontsChanged pairs in the shared-deck
  preview, quick setup, analytics panel, home box and public decks build
  their font once in a local lambda instead of twice.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:57:54 +01:00
BruebachL
396196b762
[Fonts] Migrate CardText surfaces to theme font role (#7538)
* [Fonts] Migrate CardText surfaces to theme font role

* [Fonts] Keep CardText call sites on the app font and fix px sizes

- CardText's unset fallback is qApp->font() again: only the three counter
  painters (paintNumberEllipse, GeneralCounter, PlayerCounter) were Serif
  before the role existed, and they now ask for it through
  ThemeManager::cardTextFont(). Card names, player names, counters, the
  overlay, the Archidekt and EDHREC labels and the completer delegate keep the
  app font they used before.
- Use setPixelSize for the EDHREC card name (20px) and commander name (28px)
  and details (16px) sizes: the stylesheets said px, and setPointSize made
  them about a third larger.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:57:54 +01:00
BruebachL
7a40ed9079
[Fonts] Migrate ChatLog surfaces to theme font role (#7537)
* [Fonts] Migrate ChatLog surfaces to theme font role

* [Fonts] Keep ChatView on the app font and share the report log font

- ChatLog's unset fallback is qApp->font() again: ChatView never set a font
  before the role existed, so an unset role must not switch chat and the game
  log to monospace at the system GeneralFont size.
- Move the monospace fallback the report dialogs used before into
  report_utils::reportLogFont(), which returns the theme's ChatLog role when it
  is set and the old monospace otherwise. tab_report, dlg_my_reports and
  dlg_report_user now share it, so they cannot drift and they honour a themed
  size instead of overriding it.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:57:53 +01:00
BruebachL
634f8115a5
[Fonts] Add per-purpose font section to palette editor (#7536)
* [Fonts] Add per-purpose font section to palette editor

* [Fonts] Make the editor's fallback and Reset preview-independent

- Add ThemeManager::defaultAppFont(), which resolves an unset role against the
  startup font instead of currentFonts, and use it in FontGridWidget::loadFonts
  so the grid never shows the live preview as the theme's default.
- Reload the live theme in PaletteEditorDialog::onReset before re-reading the
  grid, so Reset restores the saved fonts before loadFonts resolves fallbacks.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:57:53 +01:00
BruebachL
54797452da
[Fonts] Wire FontConfig into ThemeManager with appFont and preview (#7535)
* [Fonts] Wire FontConfig into ThemeManager with appFont and preview

* [Fonts] Restore the startup font when a config has no InterfaceDefault

- Capture the untouched application font next to the default palette and use
  it in previewFonts' else branch, so a config without InterfaceDefault
  restores the startup font instead of leaving the last preview applied.
- Drop the unpolish/polish loop from previewFonts: QApplication::setFont
  already notifies every widget, and the loop ran on every editor auto-apply.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:57:52 +01:00
BruebachL
d8700b2947
[Fonts] Add AppFont roles and FontConfig fonts.toml round-trip (#7534)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:57:52 +01:00
BruebachL
140fbb4484
[CI] Cover the legacy distros and the slow macOS runner in a nightly (#7668)
* [CI] Cover the legacy distros and the slow macOS runner in a nightly

Every pull request built ten Linux legs and five macOS/Windows legs, including
the previous release of each distro and macOS 13 on the Intel runner. Those
legs exist to catch a regression for users on an older distribution, which a
change being reviewed almost never does, and macOS 13 Intel is the slowest leg
of the matrix - 28 minutes against 19 for the rest.

They now run in a nightly build of the default branch and on tag builds, which
is when a change actually ships. Pull requests keep the current release of each
distro, the sanitizer leg and the coverage leg.

* [CI] Run the nightly legs from their own jobs

jobs.<id>.if cannot read matrix, so the previous filter never validated and
no leg of this workflow ran. matrix.exclude cannot express it either: GitHub
processes exclude before include, and these matrices are include-only.

Split the regular and nightly legs into two jobs per platform, sharing the
step list through a YAML anchor and gating the jobs on the event instead of
on matrix. The nightly run now covers only the legs a push leaves out, and
the event name is part of the concurrency group so a nightly and a master
push do not cancel each other.

* [CI] Run the coverage leg in the nightly

gcovr.cfg sets no fail_under, so the leg cannot fail a build, and nothing
consumes coverage-report: it was an artifact produced on every pull request and
read by no one. In the nightly it becomes a trend over the default branch
instead, next to the legs a change being reviewed does not wait for.

The leg itself is unchanged - same distro, version and variant, so the image
name and ccache key still match the warm cache it built. It is marked
allow-failure so a reporting leg cannot redden the nightly run that
vcpkg-cache-import requires to conclude in success.

The release-test comment loses its per-leg parenthetical: it claimed the
sanitizer leg packages, which #7666 stopped being true, and the coverage leg is
no longer in this job.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:42:35 +01:00
BruebachL
3f7aa4ca82
[CI] Split the sanitizer leg from the Ubuntu packaging leg (#7666)
The sanitize leg re-ran the Release packaging build after the instrumented
one, so 18 minutes of Release compile queued behind the sanitizer run in a
single job. Give sanitize its own leg with its own image and ccache, and
keep the plain Ubuntu leg publishing the DEB.

- The coverage leg's packaging comment now points at the leg that actually
  publishes the DEB.
- The -DENABLE_SANITIZERS=OFF comment no longer describes a debug-sanitize
  then release-package sequence that no job runs.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:24:24 +01:00
BruebachL
75beb7f302
[CI] Run the Linux test suite against the release build (#7665)
* [CI] Run the Linux test suite against the release build

A packaging leg compiled the tree twice: once in Debug to run the suite, once
in Release to package it. The suite is 655 tests that finish in four seconds,
so the Debug build bought nothing the release build cannot provide - macOS and
Windows have always tested their release build.

The sanitizer and coverage legs keep their Debug build, because that is the
configuration their instrumentation is for. Every other leg runs ctest against
the release configuration, ahead of the package target, so a failing suite
still stops the leg before anything is built into an installer. A leg that only
tests, Arch, builds Release without the crash reporting SDK the packaging legs
ship with.

Debian 13 and Fedora 44 each drop a 15 minute compile from a job that gates a
pull request.

* [CI] Keep Fedora 44 and Arch on the Debug build

Only the Debug configuration passes -Wall -Wextra -Werror, so moving
those two legs to the release build would have stopped the newest distro
toolchains from failing on a new warning. They keep the Debug compile and
test master had; the other Linux legs still test the release build.

Addresses review thread PRRT_kwDOACxbl86x2tOP (PR #7665).

* [CI] Decide the release test run once per leg

The step's if: and its shell both recomputed "does this leg test the
release build", and the two could drift into a leg that builds a release
it never tests. RUN_TESTS now holds that decision and both read it.

It lives at job level because a step's own env is not visible to that
step's if:.

Addresses review thread PRRT_kwDOACxbl86x2tOV (PR #7665).

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:22:43 +01:00
BruebachL
99422c2bf0
[Client] Stop sending the saved login when its checkbox is unticked (#7713)
Unticking 'Save password' only disabled auto-connect: the dialog still handed
the stored verifier to the connection, so a password typed at the same time was
ignored, and a server that still has the legacy hash refused the login before it
could upgrade the account.

Clear the verifier in effect on untick, restore it on re-tick unless a password
has been typed, and drop the stale 'Password saved' hint. The downgrade abort
can now point at unticking the box and logging in with the password, without the
extra reconnect round-trip.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 07:06:35 +01:00
BruebachL
96f8c3a423
[Tests] Assert settings navigation through the event player (#7698)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 06:23:45 +01:00
BruebachL
3fa8d78786
[Tests] Play card interactions through a Cockatrice event player (#7697)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 06:23:45 +01:00
BruebachL
34201122ec
[Tests] Name the tab, home and settings widgets the GUI tests address (#7696)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 06:23:44 +01:00
BruebachL
d2abd645e8
[Tests] Name the menus and dialogs the shell GUI tests drive (#7695)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 06:23:44 +01:00
BruebachL
ee57d740c2
[Tests] Replay XML test files through a headless runner (#7694)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 06:23:43 +01:00
BruebachL
2e0a718cd7
[Tests] Vendor QtTesting behind an opt-in WITH_GUI_TEST flag (#7693)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 06:23:43 +01:00
Bryce!
28597ef716
Fix Deckstats and TappedOut request lifetime and result handling (#7705)
* Fix deck analysis request lifetime, result URLs and form encoding [skip ci]

* Preserve unknown cards in deck analysis exports

* Share deck analysis URL validation and drop the unused request latch

Both analysis interfaces carried a copy of normalizeAnalysisUrl; move it to one
header next to them. They are constructed per analysis and analyzeDeck is called
once, so the analysisStarted latch was unreachable; remove it from both.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 06:22:29 +01:00
Bryce!
75d7764643
[Client] Keep Archidekt navigation tied to its active request (#7723)
* [Client] Keep Archidekt navigation tied to its active request [skip ci]

* [Client] Keep Archidekt listing pagination after a failed navigation

Only update list mode and the next-page link when a listing or deck
response is accepted, so a failed deck navigation leaves the existing
listing scrollable. Retry still re-issues the failed request.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 05:59:17 +01:00
BruebachL
81a0b97240
[Replay] Seek to the exact time of an event (#7610)
Timeline seeks only expose whole timer intervals, so jumping to an event could leave that event unprocessed until the next tick. Add an event-time seek that also applies events at the target timestamp.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 05:56:32 +01:00
Bryce!
624163916f
[Client] Keep Commander Spellbook requests bound to the active deck (#7722)
* [Client] Keep Commander Spellbook requests bound to the active deck [skip ci]

* [Client] Restore the bracket info tooltip when idle

retranslateUi() used to install the static "Why this bracket?" tooltip, but it now calls displayEstimate(), which returned early while no estimate existed. That left the info button without its idle tooltip.

Set the fallback where displayEstimate() owns the button's tooltip. An existing estimate still wins, and a language change refreshes the text.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-04 05:49:31 +01:00
Bryce!
94f7de42f2
Fix user card games for offline users and stale requests (#7738) 2026-11-04 05:29:51 +01:00
Bryce!
7768416091
Place current deck-editor docks before restoring saved layouts [skip ci] (#7734) 2026-11-04 04:37:25 +01:00
167 changed files with 5400 additions and 503 deletions

View file

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

View file

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

View file

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

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

View file

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

View 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 &amp;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>

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

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

View 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="&amp;About Cockatrice" />
<event widget="MainWindow/aboutDialog" command="key" arguments="16777216" />
</events>

View 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 &amp;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>

View 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="&amp;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 &amp;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="&amp;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>

View 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 &amp;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>

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

View 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="&amp;Manage sets..." />
<event widget="MainWindow/manageSetsWindow" command="key" arguments="16777216" />
</events>

View 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="&amp;Tip of the Day" />
<event widget="MainWindow/tipOfTheDayDialog" command="key" arguments="16777216" />
</events>

View 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 &amp;Debug Log" />
<event widget="MainWindow/viewLogDialog" command="key" arguments="16777216" />
</events>

118
Testing/run_test.sh Executable file
View 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

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

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

View 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
View 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="&amp;About Cockatrice" />
<!-- Close the modal dialog by pressing Escape (Qt::Key_Escape = 16777216) -->
<event widget="MainWindow/aboutDialog" command="key" arguments="16777216" />
</events>

View file

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

View file

@ -10,6 +10,7 @@
# or set <category> = false to disable logging
#main = true
#recording = true
#qt_translator = true
#window_main.* = true
#release_channel = true

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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

View file

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

View file

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

View 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"));
}
}

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,6 +27,7 @@ public:
private slots:
void actSubmit();
void reportResponse(const Response &response);
void applyChatLogFont();
private:
static QString categoryLabel(const QString &key);

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@ class QWidget;
DlgViewLog::DlgViewLog(QWidget *parent) : QDialog(parent)
{
setObjectName("viewLogDialog");
logArea = new QPlainTextEdit;
logArea->setReadOnly(true);

View file

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

View file

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

View file

@ -48,6 +48,7 @@ signals:
private slots:
void toggleBuddyVisibility() const;
void updateDropdownIconState() const;
void applyInterfaceDefaultFont();
};
#endif // BANNER_WIDGET_H

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,6 +24,9 @@ public:
public slots:
void paintEvent(QPaintEvent *event) override;
private slots:
void applyInterfaceDefaultFont();
private:
QPair<QColor, QColor> gradientColors;
};

View file

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

View file

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

View file

@ -55,6 +55,7 @@ private slots:
void goBack();
void skip();
void updateChrome();
void applyInterfaceDefaultFont();
private:
void addPage(FirstRunWizardPage *page);

View file

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

View file

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

View file

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

View file

@ -67,6 +67,7 @@ private slots:
void removePrintingSideboard();
void removePrintingTokensboard();
void adjustFontSize(int scalePercentage);
void applyCardTextFont();
};
#endif // CARD_AMOUNT_WIDGET_H

View file

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

View file

@ -14,6 +14,9 @@ class PrintingSelectorPlaceholderWidget : public QWidget
public:
explicit PrintingSelectorPlaceholderWidget(QWidget *parent = nullptr);
private slots:
void applyInterfaceDefaultFont();
private:
QVBoxLayout *mainLayout;
QLabel *imageLabel;

View file

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

View file

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