Compare commits

...

22 commits

Author SHA1 Message Date
Lukas Brübach
7c8b696f9d [Tests] Name the tab, home and settings widgets the GUI tests address 2026-11-02 15:46:05 +01:00
Lukas Brübach
12e03099c8 [Tests] Name the menus and dialogs the shell GUI tests drive 2026-11-02 15:46:05 +01:00
Lukas Brübach
8519082124 [Tests] Replay XML test files through a headless runner 2026-11-02 15:46:03 +01:00
Lukas Brübach
7c03e4467a [Tests] Vendor QtTesting behind an opt-in WITH_GUI_TEST flag 2026-11-02 15:46:01 +01:00
sentry[bot]
9f30892dff
[Client] Fix crash when local game commands are sent after shutdown (#7714)
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 / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (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 / 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
* [Client] Fix crash when local game commands are sent after shutdown

* [Network] Fix formatting in local client debug log

* [CI] Keep the vcpkg submodule pointer on the manifest baseline

vcpkg.json asks for builtin-baseline 9eec002 (vcpkg 2026.10.16), but the
submodule gitlink was still at 9e593bb. The runners check submodules out
shallowly, so vcpkg-tool cannot read versions/baseline.json out of the baseline
commit and every vcpkg leg (Windows and all macOS) fails in CMake configure
with:

  failed to `git show` versions/baseline.json

git phrases that as "path ... exists on disk, but not in <sha>" even when the
commit is simply absent from the shallow clone, so it reads like a tooling or
network failure rather than a mismatched pointer. Point the submodule back at
9eec002, and add a cheap job that fails when the gitlink and builtin-baseline
disagree, before the five build legs pay for it.

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 13:28:23 +01:00
sentry[bot]
8b0c980e85
[Client] Guard dragged card ghost from painting or dropping deleted cards (#7688)
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 / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (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 / 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
* [Client] Guard dragged card ghost from painting or dropping deleted cards

* [Game] Revert handling for cards destroyed mid-drag

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>

* [DeckView] Access pointer through QPointer in DeckViewCardDragItem

---------

Co-authored-by: sentry[bot] <39604003+sentry[bot]@users.noreply.github.com>
Co-authored-by: zeldazach <7460172+zeldazach@users.noreply.github.com>
2026-11-02 08:39:07 +01:00
RickyRister
62e590d300
[ServerGame] Refactor out spectator/judge bools into RoleInfo (#7761) 2026-11-02 00:37:05 -07:00
BruebachL
a1a8a59f4a
[VisualDatabase] Draw the card grid with a model/view (#7701)
* [VisualDatabase] Draw the card grid with a model/view instead of a widget per card

The visual card database built one CardInfoPictureWithTextOverlayWidget per
card, 100 per page, and tore them down on every search, resize and scroll
page. It also looked each card up again by name from the model row, kept a
shadow QList<ExactCard>, and carried paging state plus a 5 s timer that only
existed to defer the first page.

Replace that with a real view on a flattened model:

- CardDatabaseDisplayModel gains CardInfoRole and cardInfoAt(), so a view can
  get the CardInfoPtr behind a proxy row without a name round trip.
- New CardDatabaseVisualDisplayModel (libcockatrice_models) flattens the
  filtered and sorted proxy into one entry per displayed card picture: the
  preferred printing normally, every printing of the selected sets while set
  filters are active. Proxy changes rebuild it on a 30 ms coalescing timer.
- New CardDatabaseVisualView (QListView IconMode) paints only visible rows
  through the new CardPictureDelegate, keeps the old interaction contract
  (left/right/middle press -> cardClicked, hover -> cardHovered, Ctrl+wheel
  resize, no selection, no focus stealing, scroll to top on a new result) and
  repaints when CardPictureLoader reports an async image.
- CardSizeWidget::enableCtrlScrollResize() also watches scroll-area viewports,
  which is what makes Ctrl+wheel work over a QListView.
- VisualDatabaseDisplayWidget drops the paging, the shadow list, the dead
  members and the 5 s timer.

Behaviour-preserving: no gameplay change, no new user-facing strings.

* [VisualDatabase] Fix the card grid wheel step

QListView hands the delegate's item height to the scroll bar as its single step under ScrollPerPixel, so QAbstractSlider moved wheelScrollLines() whole card rows per notch. Handle the wheel in the view instead: one line is an eighth of the grid cell, so a notch moves about a third of a card row at any card size and still follows the OS line count.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 08:35:58 +01:00
BruebachL
94e6281d87
[Logs] Browse room history without filters with paginated infinite scroll (#7531)
* [Logs] Browse room history without filters with paginated infinite scroll

* [Logs] Continue pagination when switching log tabs

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:36:49 +01:00
BruebachL
3cf8bdb00e
[Tests] Add a headless game logic regression suite (#7702)
* [Tests] Add headless game logic regression suite

Cover CardState, CardZoneLogic and the Command_MoveCard protobufs PlayerActions emits, against libcockatrice_game with a recording AbstractClient and no graphics. The old game_smoke_test cases are migrated into the new suite.

* [Tests] Cover game event application through GameEventHandler

Feeds GameEventContainers through the real dispatcher and asserts the resulting zone and card state: move (including face down), the set-card-attr attributes plus the all-cards and does-not-untap branches, card counters, attach/detach, zone reveal properties, and drawing named cards from the deck.

* [Tests] Replay a synthetic game state through the dispatcher

Builds a GameReplay in code, feeds its containers through the same GameEventHandler path the client uses, and asserts the materialized players, zones, cards and attachments plus event ordering for move, attributes and counters. A small headless zone view stands in for the graphics side where processPlayerInfo asks for card items.

* [Tests] Cover card flip, destroy, token and turn events

Adds the remaining state-mutating event handlers: flip (face down keeps the ref, face up adopts the printing), destroy (removes the card and un-attaches its attached cards), create token (database P/T unless provided or face down) and player properties changed under the concede/unconcede contexts, plus active player and phase.

* [Tests] Cover player lifecycle and counter events

Adds the player-level events that mutate session state: leave removes the player from the manager, kicked emits gameClosed, game closed flips the closed flag and clears the started flag, host changed moves the host role to another player, and create/set/delete update the player counters with unknown ids ignored.

* [Tests] Drive the zone view request through addCard

The test emitted createCardRequested itself and then asserted it was emitted. Registering a ZoneViewZoneLogic on the zone and adding a card exercises the real prepareAddCard -> signal path, so the assertion now fails if addCard stops asking the graphics side to build the card.

* [Tests] Skip the card database when listing tests

gtest_discover_tests runs the binary with --gtest_list_tests during the build. That process built the card database and replaced its default path provider, leaving allocations that the sanitizer leg's default LeakSanitizer check reports at exit, so discovery failed on Ubuntu 26.04. No test runs in list mode, so return before the database setup.

* [Tests] Fix fixture comment nit

Wraps the RecordingClient::sent member name in backticks in the comment.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:35:41 +01:00
BruebachL
5134e72eb4
[Settings] Store server profiles with QSettings arrays (#7629)
The server list used hand-numbered saveName%1/server%1 keys and an O(n)
shift loop to delete entries. Store the profiles with beginReadArray and
beginWriteArray instead, and migrate the legacy keys once in the
constructor so existing profiles and saved passwords survive.

SettingsManager::batchWrite no longer disables atomic sync, so a batch is
still written through QSettings' atomic save. Add tests for add, update,
remove, password updates and the legacy migration.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:30:29 +01:00
BruebachL
b37c8b828f
[Utility] Budget scrypt's XY array in the derivation memory limit (#7712)
deriveKey passes maxmem = 128nr + 128rp + 4096 to EVP_PBE_scrypt, but scrypt
also allocates XY at 256r bytes. costParamsAreSane accepts r up to 32, so any
server asking for r >= 17 made derivation fail and the client report a login
abort it could never recover from. Cover the XY array and add a regression test
at the accepted-range boundary.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:29:15 +01:00
BruebachL
719863e43d
[CI] Lint only .sh files in the shell formatting pass (#7715)
The changed-file matcher reused the `*.sh` glob as a regular expression,
where the leading `*` applies to the `(^|/)` group instead of the file
name. Every path ending in "sh" matched, so NSIS include files (.nsh) were
handed to shellcheck and failed the format leg.

Match the suffix explicitly and keep the glob for the find(1) branch.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:29:05 +01:00
BruebachL
d021f20d14
[Version] Bump to 3.2.0 (#7762)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:28:14 +01:00
BruebachL
ce543896f5
[Tests] Use QtTest for model contracts and signal spies (#7630)
Qt6::Test was linked into every test but never used. Add a small RAII
QAbstractItemModelTester helper and apply it to the deck list model, the card
database model and display proxy, and the sets model. Add QSignalSpy coverage
for the card-added and download-settings signals, and assert explicitly that a
card row is a leaf with no child columns (the tester does not check that).

The contract checker immediately found four model bugs, fixed here:
- DeckListModel allowed children below non-zero columns.
- CardDatabaseModel reported every card as a child of every card row.
- CardDatabaseModel reported six columns for a valid (leaf) card index.
- CardDatabaseDisplayModel claimed more rows were fetchable while rowCount
  already exposed them all, so fetchMore() inserted duplicates.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:27:43 +01:00
BruebachL
429eb2a1fd
[Servatrice] Use compile-time checked connections (#7631)
* [Servatrice] Use compile-time checked connections

Replace the remaining SIGNAL/SLOT string connects in servatrice.cpp,
smtpclient.cpp, serversocketinterface.cpp and isl_interface.cpp with
pointer-to-member or lambda connects, and convert the string invokeMethod
calls to the functor overload.

QxtSmtp has overloaded signals, so those need qOverload. Slots inherited as
protected members and the QWebSocket error connection owned by the ISL fix
are left on lambdas/strings as appropriate; the vendored qxtsmtp.cpp is
untouched.

* Fix remaining review threads on the connection syntax

- Connect the IslInterface signals straight to the inherited Server slots
  instead of forwarding lambdas.
- Capture servatrice explicitly in the queued admin lambdas so they do not
  read this->servatrice after teardown.
- Move the websocket newConnection connect out of the per-pool loop.
- Terminate the logging category and restore pendingKey's indentation.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:13:21 +01:00
BruebachL
a74dfeef6e
[Build] Configure AUTOMOC, AUTOUIC and AUTORCC once (#7637)
The root already set CMAKE_AUTOMOC; add CMAKE_AUTOUIC and CMAKE_AUTORCC
next to it and drop the per-directory copies from 18 CMakeLists files.
These variables are inherited by the subdirectories, so the duplicates
never changed anything.

qt_standard_project_setup is not used: besides the AUTO* variables it
sets CMAKE_RUNTIME_OUTPUT_DIRECTORY on Windows, which breaks the
install(DIRECTORY "$<TARGET_FILE_DIR:...>") copies and the per-app
symbol paths, and appends to CMAKE_INSTALL_RPATH on Linux.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:06:47 +01:00
BruebachL
5fe7f186d3
[CI] Make the hdiutil retry wrapper report failure (#7643)
* [CI] Make the hdiutil retry wrapper report failure

CPack reads the wrapper's exit status to decide whether the disk image was
created, so a zero exit turns a failed hdiutil into a later "Problem copying
the package" error that hides hdiutil's own message.

- Propagate hdiutil's real status instead of collapsing every failure to 1.
- Name the failing subcommand (create/attach/detach/convert) so the message
  says which CPack step gave up.
- Count attempts rather than failures in the retry line.

* Update .ci/compile.sh

Co-authored-by: tooomm <tooomm@users.noreply.github.com>

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: tooomm <tooomm@users.noreply.github.com>
2026-11-02 07:01:55 +01:00
BruebachL
50a2ae9cc8
[Server/Client] Enforce and complete the forced password change at login (#7680)
checkUserPassword ignored force_password_change on the challenge-response
path, so a reset password stayed valid. Returning PasswordChangeRequired
was not enough on its own: the session was dropped and the client told the
user to ask the administrator for the temporary password they had just used.

The pending session now keeps its identity without registering a user
session or announcing a join, may only ping and change its password, and
the client completes the change in-session before reconnecting.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:01:29 +01:00
BruebachL
ef4574d2b9
[Server] Store a scrypt verifier when an admin resets a password (#7679)
The temporary password was written as a legacy salt+hash row, which makes the
next login look like an authentication downgrade. A client holding a saved
verifier refuses that downgrade, so the reset locked the account out of every
modern client.

Store the verifier and, where security/legacy_backup_key is configured, keep a
sealed legacy hash of the same temporary password as the downgrade backup, so
pre-challenge clients keep the credential path the reset used to provide.

Without the key no row shape serves both: the verifier and the legacy hash
cannot share one column, so those clients have no path at all. Warn at reset
time that the new password has no pre-challenge path, and say so in
servatrice.ini.example instead of promising that a reset gets them back in.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-02 07:00:48 +01:00
tooomm
e08402ac94
bump vcpkg submodule (#7690)
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 / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (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 / 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
2026-11-01 16:22:51 +01:00
BruebachL
ff393b8990
[Game] Connect zone dump response before sending the request (#7711)
ZoneViewZone::initializeCards() connected PendingCommand::finished only after ZoneViewZoneLogic::requestZoneDump() had already sent the command. Local games process the dump synchronously, so finished was emitted before the view connected and the library/sideboard view stayed empty.

Split requestZoneDump() into prepareZoneDump() and sendZoneDump(), and connect before sending, matching the TabRoom prepare/send convention.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-01 12:49:35 +01:00
151 changed files with 5555 additions and 1061 deletions

View file

@ -311,16 +311,24 @@ if [[ $RUNNER_OS == macOS ]]; then
if [[ $MAKE_PACKAGE ]]; then
# Workaround https://github.com/actions/runner-images/issues/7522
# have hdiutil repeat the command 10 times in hope of success
# have hdiutil repeat the command 10 times in hope of success.
# The wrapper has to exit non-zero once it gives up: CPack reads that status to
# decide whether the disk image was created, so a zero exit turns a failed hdiutil
# into a much later, misleading "Problem copying the package" error that hides
# hdiutil's own message.
hdiutil_script="/tmp/hdiutil.sh"
# shellcheck disable=SC2016
echo '#!/bin/bash
attempts=10
i=0
while ! hdiutil "$@"; do
if (( ++i >= 10 )); then
echo "Error: hdiutil failed $i times!" >&2
break
while true; do
hdiutil "$@" && exit 0
rc=$?
if (( ++i >= attempts )); then
echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2
exit "$rc"
fi
echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2
sleep 1
done' >"$hdiutil_script"
chmod +x "$hdiutil_script"

View file

@ -101,6 +101,36 @@ jobs:
--title "$release_name" \
--notes-file "$body_path"
vcpkg-baseline:
name: vcpkg baseline
runs-on: ubuntu-slim
steps:
- name: "Checkout"
uses: actions/checkout@v7
# vcpkg-tool reads versions/baseline.json out of the commit named by
# `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule
# pointer that lags the manifest - e.g. a stale `vcpkg/` worktree captured by
# `git add` in a PR - makes every vcpkg leg fail in configure with "failed to
# `git show` versions/baseline.json". That reads like a baseline or network
# problem, when the pointer is what has to move. Check the two agree here in
# seconds instead of on five build legs.
- name: "Check the submodule pointer against the manifest baseline"
shell: bash
run: |
pointer="$(git rev-parse HEAD:vcpkg 2>/dev/null || true)"
baseline="$(sed -nE 's/.*"builtin-baseline"[[:space:]]*:[[:space:]]*"([0-9a-f]{40})".*/\1/p' vcpkg.json)"
if [[ -z "$pointer" || -z "$baseline" ]]; then
echo "::error file=vcpkg.json::could not read the vcpkg submodule pointer or the builtin-baseline"
exit 1
fi
if [[ "$pointer" != "$baseline" ]]; then
echo "::error file=vcpkg.json::vcpkg submodule is at $pointer but builtin-baseline is $baseline"
echo "Move them together: git -C vcpkg checkout $baseline && git add vcpkg"
exit 1
fi
echo "vcpkg submodule and builtin-baseline agree on $baseline"
build-linux:
strategy:
fail-fast: false
@ -466,7 +496,7 @@ jobs:
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}
needs: configure
needs: [configure, vcpkg-baseline]
runs-on: ${{ matrix.runner }}
timeout-minutes: 100
env:

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)
@ -90,7 +100,7 @@ endif()
# A project name is needed for CPack
# Version can be overriden by git tags, see cmake/getversion.cmake
project("Cockatrice" VERSION 3.1.2)
project("Cockatrice" VERSION 3.2.0)
# Set release name if not provided via env/cmake var
if(NOT DEFINED GIT_TAG_RELEASENAME)
@ -259,7 +269,13 @@ else()
add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x060400)
endif()
# Set once here for every target: these variables are inherited by the
# subdirectories, so the per-directory copies were redundant. Only the
# variables are set; qt_standard_project_setup would also change the runtime
# output directories and the install RPATH.
set(CMAKE_AUTOMOC TRUE)
set(CMAKE_AUTOUIC TRUE)
set(CMAKE_AUTORCC TRUE)
# Find other needed libraries
find_package(Protobuf CONFIG)
@ -455,6 +471,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)

52
Testing/README.md Normal file
View file

@ -0,0 +1,52 @@
# 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 |

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

@ -171,6 +171,7 @@ set(cockatrice_SOURCES
src/interface/widgets/cards/card_info_picture_widget.cpp
src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp
src/interface/widgets/cards/card_info_text_widget.cpp
src/interface/widgets/cards/card_picture_delegate.cpp
src/interface/widgets/cards/card_size_widget.cpp
src/interface/widgets/cards/deck_card_zone_display_widget.cpp
src/interface/widgets/cards/deck_preview_card_picture_widget.cpp
@ -290,6 +291,7 @@ set(cockatrice_SOURCES
src/interface/widgets/utility/sequence_edit.cpp
src/interface/widgets/utility/visibility_change_listener.cpp
src/interface/widgets/utility/visibility_change_listener.h
src/interface/widgets/visual_database_display/card_database_visual_view.cpp
src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp
src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp
src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp
@ -562,6 +564,14 @@ target_link_libraries(
cockatrice_target_enable_crash_reporting(cockatrice)
if(WITH_GUI_TEST)
# recording.cpp translates between XML test files and the QtTesting event pipeline;
# it needs the QtTesting headers, so it only exists with the harness.
target_sources(cockatrice PRIVATE 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

@ -3,6 +3,7 @@
#include "../../../interface/pixel_map_generator.h"
#include "../../settings/cache_settings.h"
#include "../interface/widgets/dialogs/dlg_connect.h"
#include "../interface/widgets/dialogs/dlg_edit_password.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_request.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_reset.h"
@ -99,6 +100,10 @@ void ConnectionController::wireClientSignals()
connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this,
&ConnectionController::onPasswordVerifierReady);
connect(remoteClient, &RemoteClient::sigPasswordChangeSuccess, this,
&ConnectionController::onPasswordChangeSuccess);
connect(remoteClient, &RemoteClient::sigPasswordChangeError, this, &ConnectionController::onPasswordChangeError);
}
void ConnectionController::connectToServer()
@ -351,10 +356,16 @@ void ConnectionController::onLoginError(int r,
}
case Response::RespPasswordChangeRequired: {
QMessageBox::information(
dialogParent, tr("Password Change Required"),
tr("An administrator has reset your password. Please contact your server administrator to obtain "
"your temporary password, then log in and change it via Account -> Change Password."));
// The credentials were right, but the account may not be used until a new password
// is chosen. Let the user complete the change here: sending them off to "ask the
// administrator for your temporary password" sends them in circles, since they just
// logged in with it and the session ends without a way to comply.
DlgEditPassword dlg(dialogParent);
dlg.setWindowTitle(tr("Password Change Required"));
if (dlg.exec()) {
remoteClient->changePasswordToServer(dlg.getOldPassword(), dlg.getNewPassword());
return; // onPasswordChangeSuccess/onPasswordChangeError decides the next step
}
remoteClient->disconnectFromServer();
return;
}
@ -517,6 +528,43 @@ void ConnectionController::onNotifyUserAboutUpdate()
"To update your client, go to Help -> Check for Updates."));
}
void ConnectionController::onPasswordChangeSuccess()
{
// The credential saved for this profile is stale now: drop it so the next login asks for
// the new password instead of replaying the old verifier.
if (!pendingSaveName.isEmpty()) {
SettingsCache::instance().servers().setServerPassword(pendingSaveName, "");
}
QMessageBox::information(dialogParent, tr("Password Changed"),
tr("Your password has been changed. Please log in again with your new password."));
remoteClient->disconnectFromServer();
connectToServer();
}
void ConnectionController::onPasswordChangeError(int responseCode)
{
QString reason;
switch (static_cast<Response::ResponseCode>(responseCode)) {
case Response::RespWrongPassword:
reason = tr("The current password is incorrect.");
break;
case Response::RespPasswordTooShort:
reason = tr("The new password is too short.");
break;
case Response::RespClientUpdateRequired:
reason = tr("The server does not accept the new password format.");
break;
default:
reason = tr("The password could not be changed (error %1).").arg(responseCode);
break;
}
QMessageBox::critical(dialogParent, tr("Password Change Failed"), reason);
remoteClient->disconnectFromServer();
connectToServer();
}
void ConnectionController::onForgotPasswordSuccess()
{
QMessageBox::information(

View file

@ -94,6 +94,10 @@ private slots:
// Persists the derived scrypt verifier after a successful challenge-response login
void onPasswordVerifierReady(const QString &verifier);
// Completes the forced password change the server demanded at login
void onPasswordChangeSuccess();
void onPasswordChangeError(int responseCode);
private:
void wireClientSignals();
void updateWindowTitle();

View file

@ -11,6 +11,27 @@
class QObject;
namespace
{
/**
* @brief Visits every filter item below @p node, depth first.
*/
template <typename Callback> void forEachFilter(const FilterTreeNode *node, const Callback &callback)
{
if (node == nullptr) {
return;
}
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
callback(filterItem);
}
for (int i = 0; i < node->childCount(); ++i) {
forEachFilter(node->nodeAt(i), callback);
}
}
} // namespace
FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent)
{
fTree = new FilterTree;
@ -110,46 +131,31 @@ void FilterTreeModel::clearFiltersOfType(CardFilter::Attr filterType)
QList<const CardFilter *> FilterTreeModel::getFiltersOfType(CardFilter::Attr filterType) const
{
QList<const CardFilter *> filters;
if (!fTree) {
return filters;
}
std::function<void(const FilterTreeNode *)> traverse;
traverse = [&](const FilterTreeNode *node) {
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
if (filterItem->attr() == filterType) {
QString text = filterItem->text();
filters.append(new CardFilter(text, filterItem->type(), filterItem->attr()));
}
forEachFilter(fTree, [&](const FilterItem *filterItem) {
if (filterItem->attr() == filterType) {
filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr()));
}
for (int i = 0; i < node->childCount(); ++i) {
traverse(node->nodeAt(i));
}
};
traverse(fTree);
});
return filters;
}
QStringList FilterTreeModel::getFilterTermsOfType(CardFilter::Attr filterType) const
{
QStringList terms;
forEachFilter(fTree, [&](const FilterItem *filterItem) {
if (filterItem->attr() == filterType) {
terms.append(filterItem->text());
}
});
return terms;
}
QList<const CardFilter *> FilterTreeModel::allFilters() const
{
QList<const CardFilter *> filters;
if (!fTree) {
return filters;
}
std::function<void(const FilterTreeNode *)> traverse;
traverse = [&](const FilterTreeNode *node) {
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
QString text = filterItem->text();
filters.append(new CardFilter(text, filterItem->type(), filterItem->attr()));
}
for (int i = 0; i < node->childCount(); ++i) {
traverse(node->nodeAt(i));
}
};
traverse(fTree);
forEachFilter(fTree, [&](const FilterItem *filterItem) {
filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr()));
});
return filters;
}

View file

@ -28,7 +28,14 @@ public slots:
void addFilter(const CardFilter *f);
void removeFilter(const CardFilter *f);
void clearFiltersOfType(CardFilter::Attr filterType);
/**
* @brief Copies every active filter of one attribute into new CardFilter objects.
*
* The caller owns the returned filters. Prefer getFilterTermsOfType() when only
* the terms are needed.
*/
[[nodiscard]] QList<const CardFilter *> getFiltersOfType(CardFilter::Attr filterType) const;
/// @brief Copies every active filter into new CardFilter objects. The caller owns them.
[[nodiscard]] QList<const CardFilter *> allFilters() const;
private slots:
@ -48,6 +55,13 @@ public:
{
return fTree;
}
/**
* @brief The texts of every active filter of one attribute, without allocating filters.
*
* Prefer this over getFiltersOfType() when only the filter terms are needed.
*/
[[nodiscard]] QStringList getFilterTermsOfType(CardFilter::Attr filterType) const;
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] int columnCount(const QModelIndex & /*parent*/ = QModelIndex()) const override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;

View file

@ -7,6 +7,7 @@
#include <QBrush>
#include <QColor>
#include <QCursor>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QPainter>
#include <QPainterPath>
@ -51,7 +52,20 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item,
update();
});
connect(item, &QObject::destroyed, this, &AbstractCardDragItem::deleteLater);
// The source card can be destroyed mid-drag (e.g. moved by a server event). deleteLater() alone is not enough:
// a scene repaint can run before the deferred delete and paint the dead card. Detach from the scene immediately.
connect(_item, &QObject::destroyed, this, [this]() {
hide();
if (QGraphicsScene *sc = scene()) {
sc->removeItem(this);
}
deleteLater();
});
}
AbstractCardItem *AbstractCardDragItem::getItem() const
{
return item.data();
}
QPainterPath AbstractCardDragItem::shape() const
@ -65,6 +79,10 @@ QPainterPath AbstractCardDragItem::shape() const
void AbstractCardDragItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
if (item.isNull()) {
return;
}
item->paint(painter, option, widget);
// adds a mask to the card so it looks like the card hasnt been placed yet

View file

@ -14,6 +14,7 @@
#include <QList>
#include <QObject>
#include <QPointF>
#include <QPointer>
#include <QRectF>
#include <qtmetamacros.h>
@ -24,7 +25,8 @@ class AbstractCardDragItem : public QObject, public QGraphicsItem
Q_OBJECT
Q_INTERFACES(QGraphicsItem)
protected:
AbstractCardItem *item;
/// Guarded pointer: the source card may be destroyed while this drag item still exists.
QPointer<AbstractCardItem> item;
QPointF hotSpot;
QList<AbstractCardDragItem *> childDrags;
@ -44,10 +46,8 @@ public:
}
[[nodiscard]] QPainterPath shape() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
[[nodiscard]] AbstractCardItem *getItem() const
{
return item;
}
/// @return the source card, or nullptr if it has already been destroyed.
[[nodiscard]] AbstractCardItem *getItem() const;
[[nodiscard]] QPointF getHotSpot() const
{
return hotSpot;

View file

@ -130,8 +130,9 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
sc->removeItem(this);
QList<CardDragItem *> dragItemList;
CardZoneLogic *startZone = static_cast<CardItem *>(item)->getZone();
if (currentZone && !(static_cast<CardItem *>(item)->getAttachedTo() && (startZone == currentZone->getLogic()))) {
CardZoneLogic *startZone = static_cast<CardItem *>(item.data())->getZone();
if (currentZone &&
!(static_cast<CardItem *>(item.data())->getAttachedTo() && (startZone == currentZone->getLogic()))) {
if (!occupied) {
dragItemList.append(this);
}
@ -139,7 +140,7 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
for (int i = 0; i < childDrags.size(); i++) {
CardDragItem *c = static_cast<CardDragItem *>(childDrags[i]);
if (!occupied &&
!(static_cast<CardItem *>(c->item)->getAttachedTo() && (startZone == currentZone->getLogic())) &&
!(static_cast<CardItem *>(c->item.data())->getAttachedTo() && (startZone == currentZone->getLogic())) &&
!c->occupied) {
dragItemList.append(c);
}

View file

@ -77,7 +77,7 @@ void DeckViewCardDragItem::updatePosition(const QPointF &cursorScenePos)
void DeckViewCardDragItem::handleDrop(DeckViewCardContainer *target)
{
auto *card = static_cast<DeckViewCard *>(item);
auto *card = static_cast<DeckViewCard *>(item.data());
auto *start = static_cast<DeckViewCardContainer *>(item->parentItem());
start->removeCard(card);
target->addCard(card);

View file

@ -99,9 +99,13 @@ void ZoneViewZone::initializeCards(const QList<const ServerInfo_Card *> &cardLis
}
reorganizeCards();
} else if (!qobject_cast<ZoneViewZoneLogic *>(getLogic())->getOriginalZone()->contentsKnown()) {
PendingCommand *pend = qobject_cast<ZoneViewZoneLogic *>(getLogic())->requestZoneDump();
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
PendingCommand *pend = viewLogic->prepareZoneDump();
if (pend != nullptr) {
// A local game answers synchronously, so the handler has to be connected before the
// command is sent or the response would be delivered to nobody.
connect(pend, &PendingCommand::finished, this, &ZoneViewZone::zoneDumpReceived);
viewLogic->sendZoneDump(pend);
}
} else {
const CardList &c = qobject_cast<ZoneViewZoneLogic *>(getLogic())->getOriginalZone()->getCards();

View file

@ -238,6 +238,7 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
});
card.emitPixmapUpdated();
emit getInstance().pixmapLoaded(card);
}
void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card)

View file

@ -146,6 +146,14 @@ signals:
/** @brief Emitted after the worker has finished clearing the network and redirect caches. */
void networkCacheCleared();
/**
* @brief Emitted once an image has been loaded and put into the pixmap cache.
*
* Views that paint cards from the loader without owning a widget per card can
* listen to this to repaint the rows whose picture just became available.
*/
void pixmapLoaded(const ExactCard &card);
private slots:
/**
* @brief Triggered when the user changes the picture download settings.

View file

@ -27,9 +27,9 @@ void IntentGetLoginCredentials::onPreconditionSatisfied()
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(context->hostname, context->port);
if (index >= 0) {
context->username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
context->password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
if (const auto server = servers.getServer(index)) {
context->username = server->username;
context->password = server->password;
emitFinished();
} else {
emitFailed(tr("No saved credentials for this server"));
@ -69,9 +69,8 @@ void IntentGetLoginCredentials::onPreconditionNotSatisfied()
// it with the raw hostname when addNewServer updates the entry in place.
QString saveName = context->hostname;
const int existingIndex = servers.findServerIndex(context->hostname, context->port);
if (existingIndex >= 0) {
saveName =
servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString();
if (const auto existing = servers.getServer(existingIndex)) {
saveName = existing->saveName;
if (saveName.isEmpty()) {
saveName = context->hostname;
}

View file

@ -418,12 +418,10 @@ void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain)
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort);
if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
const QString username =
servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
const QString password =
servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password);
const auto server = servers.getServer(index);
if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username,
server->password);
return;
}
client->disconnectFromServer();
@ -438,11 +436,9 @@ void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain)
// when credentials are saved, otherwise stay offline.
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort);
if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
const QString username =
servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
const QString password =
servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password);
const auto server = servers.getServer(index);
if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username,
server->password);
}
}

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

@ -43,11 +43,6 @@
#include <qlogging.h>
#include <qnamespace.h>
static constexpr qreal MTG_CARD_ASPECT_RATIO = 1.396;
// static constexpr qreal YUGIOH_CARD_ASPECT_RATIO = 1.457;
static constexpr qreal ASPECT_RATIO = MTG_CARD_ASPECT_RATIO;
static constexpr int BASE_WIDTH = 200;
static constexpr int BASE_HEIGHT = 200;
static constexpr int ENLARGED_PIXMAP_OFFSET = 10;
@ -162,12 +157,11 @@ void CardInfoPictureWidget::resizeEvent(QResizeEvent *event)
*/
void CardInfoPictureWidget::setScaleFactor(const int scale)
{
const int newWidth = BASE_WIDTH * scale / 100;
const int newHeight = static_cast<int>(newWidth * ASPECT_RATIO);
const QSize newSize = sizeForScaleFactor(scale);
scaleFactor = scale;
setFixedSize(newWidth, newHeight);
setFixedSize(newSize);
updatePixmap();
emit cardScaleFactorChanged(scale);
@ -266,8 +260,13 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event)
*/
QSize CardInfoPictureWidget::sizeHint() const
{
return {static_cast<int>(BASE_WIDTH * scaleFactor / 100.0),
static_cast<int>(BASE_WIDTH * scaleFactor / 100.0 * ASPECT_RATIO)};
return sizeForScaleFactor(scaleFactor);
}
QSize CardInfoPictureWidget::sizeForScaleFactor(int scaleFactor)
{
const int width = BASE_WIDTH * scaleFactor / 100;
return {width, static_cast<int>(width * ASPECT_RATIO)};
}
/**

View file

@ -28,6 +28,11 @@ class CardInfoPictureWidget : public QWidget
Q_OBJECT
public:
/// Width of a card picture at 100 % scale, in pixels.
static constexpr int BASE_WIDTH = 200;
/// Height / width of a card picture.
static constexpr qreal ASPECT_RATIO = 1.396;
explicit CardInfoPictureWidget(QWidget *parent = nullptr,
bool hoverToZoomEnabled = false,
bool raiseOnEnter = false);
@ -37,6 +42,12 @@ public:
}
[[nodiscard]] QSize sizeHint() const override;
/**
* @brief The size a card picture has at @p scaleFactor percent.
* @param scaleFactor Percentage of BASE_WIDTH, as set by the card size slider.
*/
[[nodiscard]] static QSize sizeForScaleFactor(int scaleFactor);
public slots:
void setCard(const ExactCard &card);
void setScaleFactor(int scale); // New slot for scaling

View file

@ -0,0 +1,83 @@
#include "card_picture_delegate.h"
#include "../../../client/settings/cache_settings.h"
#include "../../card_picture_loader/card_picture_loader.h"
#include "card_art_utils.h"
#include "card_database_visual_display_model.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/settings/cards_display_settings.h"
#include <QPainter>
#include <QPainterPath>
#include <QPixmap>
#include <QRect>
#include <QStyleOptionViewItem>
#include <libcockatrice/card/printing/printing_info.h>
CardPictureDelegate::CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent)
: QStyledItemDelegate(parent), model(_model)
{
}
void CardPictureDelegate::setCardSize(const QSize &size)
{
if (cardSize == size) {
return;
}
cardSize = size;
}
void CardPictureDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
if (model == nullptr) {
return;
}
const ExactCard card = model->cardAt(index.row());
if (!card) {
return;
}
// The grid cell is the card plus SPACING; give the picture the rest.
const int inset = SPACING / 2;
const QRect cardArea = option.rect.adjusted(inset, inset, -inset, -inset);
QPixmap pixmap;
CardPictureLoader::getCardBackLoadingInProgressPixmap(pixmap, cardArea.size());
CardPictureLoader::getPixmap(pixmap, card, cardArea.size());
if (pixmap.isNull()) {
return;
}
if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) {
pixmap = CardArtUtils::rotateSidewaysLayoutArt(pixmap, card);
}
// Fit the picture into the cell and center it, like CardInfoPictureWidget does.
const QSize pictureSize = pixmap.deviceIndependentSize().toSize().scaled(cardArea.size(), Qt::KeepAspectRatio);
const QRect pictureRect(cardArea.center().x() - pictureSize.width() / 2,
cardArea.center().y() - pictureSize.height() / 2, pictureSize.width(),
pictureSize.height());
const qreal radius = SettingsCache::instance().cardsDisplay().getRoundCardCorners()
? 0.05 * static_cast<qreal>(pictureRect.width())
: 0.0;
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
painter->setRenderHint(QPainter::SmoothPixmapTransform);
QPainterPath shape;
shape.addRoundedRect(pictureRect, radius, radius);
painter->setClipPath(shape);
// Source rect is in device pixels, target in logical ones: drawPixmap scales
// between them without the device pixel ratio being applied twice.
painter->drawPixmap(pictureRect, pixmap, QRectF(pixmap.rect()));
painter->restore();
}
QSize CardPictureDelegate::sizeHint(const QStyleOptionViewItem & /*option*/, const QModelIndex & /*index*/) const
{
return cardSize + QSize(SPACING, SPACING);
}

View file

@ -0,0 +1,45 @@
/**
* @file card_picture_delegate.h
* @ingroup CardWidgets
* @brief Paints a card picture for a visual card grid item.
*/
#ifndef CARD_PICTURE_DELEGATE_H
#define CARD_PICTURE_DELEGATE_H
#include <QSize>
#include <QStyledItemDelegate>
#include <qtmetamacros.h>
class CardDatabaseVisualDisplayModel;
/**
* @brief Draws the card of a CardDatabaseVisualDisplayModel row as a picture.
*
* The delegate only paints rows the view asks for, so a grid of the whole card
* database stays cheap: no widget per card and no pixmap loaded for items that
* are off screen. Card images come from CardPictureLoader, which caches them and
* loads missing ones asynchronously.
*/
class CardPictureDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
/// Gap left around a card picture inside its grid cell, in pixels.
static constexpr int SPACING = 4;
explicit CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent = nullptr);
/// The picture size inside a grid cell. The cell is this size plus SPACING.
void setCardSize(const QSize &size);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
[[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
private:
CardDatabaseVisualDisplayModel *model;
QSize cardSize;
};
#endif // CARD_PICTURE_DELEGATE_H

View file

@ -2,6 +2,7 @@
#include "../general/layout_containers/flow_widget.h"
#include <QAbstractScrollArea>
#include <QEvent>
#include <QFlags>
#include <QHBoxLayout>
@ -33,8 +34,8 @@ CardSizeWidget::CardSizeWidget(QWidget *parent, FlowWidget *_flowWidget, int def
cardSizeLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
cardSizeSlider = new QSlider(Qt::Horizontal, this);
cardSizeSlider->setRange(50, 250); ///< Slider range for card size adjustment.
cardSizeSlider->setValue(defaultValue); ///< Initial slider value.
cardSizeSlider->setRange(MIN_CARD_SIZE, MAX_CARD_SIZE); ///< Slider range for card size adjustment.
cardSizeSlider->setValue(defaultValue); ///< Initial slider value.
cardSizeLayout->addWidget(cardSizeLabel);
cardSizeLayout->addWidget(cardSizeSlider);
@ -73,13 +74,26 @@ QSlider *CardSizeWidget::getSlider() const
void CardSizeWidget::enableCtrlScrollResize(QWidget *host)
{
host->installEventFilter(this);
const auto scrollAreas = host->findChildren<QScrollArea *>();
for (QScrollArea *scrollArea : scrollAreas) {
if (QWidget *content = scrollArea->widget()) {
content->installEventFilter(this);
const auto watchScrollArea = [this](QAbstractScrollArea *scrollArea) {
if (scrollArea == nullptr) {
return;
}
if (QWidget *viewport = scrollArea->viewport()) {
viewport->installEventFilter(this);
}
if (auto *contentScrollArea = qobject_cast<QScrollArea *>(scrollArea)) {
if (QWidget *content = contentScrollArea->widget()) {
content->installEventFilter(this);
}
}
};
host->installEventFilter(this);
watchScrollArea(qobject_cast<QAbstractScrollArea *>(host));
const auto scrollAreas = host->findChildren<QAbstractScrollArea *>();
for (QAbstractScrollArea *scrollArea : scrollAreas) {
watchScrollArea(scrollArea);
}
}

View file

@ -24,6 +24,10 @@ class CardSizeWidget : public QWidget
Q_OBJECT
public:
/// Range of the card size slider, as a percentage of the base card width.
static constexpr int MIN_CARD_SIZE = 50;
static constexpr int MAX_CARD_SIZE = 250;
explicit CardSizeWidget(QWidget *parent, FlowWidget *flowWidget = nullptr, int defaultValue = 100);
[[nodiscard]] QSlider *getSlider() const;
@ -31,8 +35,9 @@ public:
* @brief Resizes the cards when the user Ctrl + scrolls over @p host or any of its descendants.
*
* Installs this widget as an event filter on the given host widget. If the host contains a
* scroll area the filter is also installed on the scroll area's content widget, so the resize
* intercepts the wheel event before the scroll area would scroll the view.
* scroll area the filter is also installed on the scroll area's content widget, and on the
* viewport of every scroll area base (QListView, QTableView, ...), so the resize intercepts
* the wheel event before the scroll area would scroll the view.
*/
void enableCtrlScrollResize(QWidget *host);

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

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

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

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

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

@ -119,6 +119,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,6 +137,7 @@ 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);

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,6 +288,7 @@ void HomeWidget::updateButtonsToBackgroundColor()
QGroupBox *HomeWidget::createButtons()
{
QGroupBox *box = new QGroupBox(this);
box->setObjectName("homeButtonsBox");
box->setStyleSheet(R"(
QGroupBox {
font-size: 20px;
@ -311,27 +313,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())) {

View file

@ -32,7 +32,6 @@
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pb/session_commands.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/passwordhasher.h>
#include <libcockatrice/utility/qt_utils.h>
#include <string>
#include <sys/types.h>
@ -299,17 +298,7 @@ void UserInfoBox::actPassword()
void UserInfoBox::changePassword(const QString &oldPassword, const QString &newPassword)
{
Command_AccountPassword cmd;
cmd.set_old_password(oldPassword.toStdString());
if (client->getServerSupportsChallengeResponse()) {
cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString());
} else if (client->getServerSupportsPasswordHash()) {
auto passwordSalt = PasswordHasher::generateRandomSalt();
QString hashedPassword = PasswordHasher::computeHash(newPassword, passwordSalt);
cmd.set_hashed_new_password(hashedPassword.toStdString());
} else {
cmd.set_new_password(newPassword.toStdString());
}
const Command_AccountPassword cmd = client->makeAccountPasswordCommand(oldPassword, newPassword);
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &UserInfoBox::processPasswordResponse);

View file

@ -29,19 +29,10 @@ QMap<QString, std::pair<QString, UserConnection_Information>> UserConnection_Inf
ServersSettings &servers = SettingsCache::instance().servers();
int size = servers.getValue("totalServers", "server", "server_details").toInt() + 1;
for (int i = 0; i < size; i++) {
QString _saveName = servers.getValue(QString("saveName%1").arg(i), "server", "server_details").toString();
QString serverName = servers.getValue(QString("server%1").arg(i), "server", "server_details").toString();
QString portNum = servers.getValue(QString("port%1").arg(i), "server", "server_details").toString();
QString userName = servers.getValue(QString("username%1").arg(i), "server", "server_details").toString();
QString pass = servers.getValue(QString("password%1").arg(i), "server", "server_details").toString();
bool savePass = servers.getValue(QString("savePassword%1").arg(i), "server", "server_details").toBool();
QString _site = servers.getValue(QString("site%1").arg(i), "server", "server_details").toString();
UserConnection_Information userInfo(_saveName, serverName, portNum, userName, pass, savePass, _site);
serverList.insert(_saveName, std::make_pair(serverName, userInfo));
for (const auto &entry : servers.getServers()) {
UserConnection_Information userInfo(entry.saveName, entry.host, entry.port, entry.username, entry.password,
entry.savePassword, entry.site);
serverList.insert(entry.saveName, std::make_pair(entry.host, userInfo));
}
return serverList;
@ -53,28 +44,18 @@ QStringList UserConnection_Information::getServerInfo(const QString &find)
ServersSettings &servers = SettingsCache::instance().servers();
int size = servers.getValue("totalServers", "server", "server_details").toInt() + 1;
for (int i = 0; i < size; i++) {
QString _saveName = servers.getValue(QString("saveName%1").arg(i), "server", "server_details").toString();
if (find != _saveName) {
for (const auto &entry : servers.getServers()) {
if (find != entry.saveName) {
continue;
}
QString serverName = servers.getValue(QString("server%1").arg(i), "server", "server_details").toString();
QString portNum = servers.getValue(QString("port%1").arg(i), "server", "server_details").toString();
QString userName = servers.getValue(QString("username%1").arg(i), "server", "server_details").toString();
QString pass = servers.getValue(QString("password%1").arg(i), "server", "server_details").toString();
bool savePass = servers.getValue(QString("savePassword%1").arg(i), "server", "server_details").toBool();
QString _site = servers.getValue(QString("site%1").arg(i), "server", "server_details").toString();
_server.append(_saveName);
_server.append(serverName);
_server.append(portNum);
_server.append(userName);
_server.append(pass);
_server.append(savePass ? "1" : "0");
_server.append(_site);
_server.append(entry.saveName);
_server.append(entry.host);
_server.append(entry.port);
_server.append(entry.username);
_server.append(entry.password);
_server.append(entry.savePassword ? "1" : "0");
_server.append(entry.site);
break;
}

View file

@ -6,6 +6,7 @@ class TabSupervisor;
TabHome::TabHome(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client)
{
setObjectName("TabHome");
homeWidget = new HomeWidget(this, tabSupervisor);
setCentralWidget(homeWidget);
}

View file

@ -13,6 +13,7 @@
#include <QMessageBox>
#include <QPushButton>
#include <QRadioButton>
#include <QScrollBar>
#include <QSharedPointer>
#include <QSizePolicy>
#include <QSpinBox>
@ -21,6 +22,7 @@
#include <QTableWidgetItem>
#include <QVBoxLayout>
#include <QWidget>
#include <QtGlobal>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/response_viewlog_history.pb.h>
@ -31,6 +33,17 @@
class TabSupervisor;
namespace
{
/** @brief Identifies the newest row of a page well enough to recognise a repeated page. */
QString pageStartKey(const Response_ViewLogHistory &response)
{
const ServerInfo_ChatMessage &first = response.log_message(0);
return QString::fromStdString(first.time()) + QChar(0x1f) + QString::fromStdString(first.sender_name()) +
QChar(0x1f) + QString::fromStdString(first.message());
}
} // namespace
TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands)
: Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands)
{
@ -52,12 +65,20 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can
chatTable->setHorizontalHeaderLabels(
QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";"));
QTabWidget *tabManager = new QTabWidget();
tabManager = new QTabWidget();
tabManager->addTab(roomTable, tr("Room Logs"));
tabManager->addTab(gameTable, tr("Game Logs"));
tabManager->addTab(chatTable, tr("Chat Logs"));
setCentralWidget(tabManager);
connect(roomTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore);
connect(gameTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore);
connect(chatTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore);
// A tab that gains only a few rows has nothing to scroll, so its scrollbar never fires:
// switching to it would leave its next page unfetched until the moderator scrolled
// another tab. Re-check on every tab switch so the visible table can continue the browse.
connect(tabManager, &QTabWidget::currentChanged, this, &TabLog::checkFetchMore);
createDock();
restartLayout();
clearClicked();
@ -74,12 +95,9 @@ void TabLog::retranslateUi()
void TabLog::getClicked()
{
if (findUsername->text().isEmpty() && findIPAddress->text().isEmpty() && findGameName->text().isEmpty() &&
findGameID->text().isEmpty() && findMessage->text().isEmpty()) {
QMessageBox::critical(this, tr("Error"), tr("You must select at least one filter."));
return;
}
// Empty filters are a valid browse: room/game history going back from
// today, paginated. Only require a date range + location, both of which
// get sensible defaults below so a bare "Get" just works.
if (!lastHour->isChecked() && !today->isChecked() && !pastDays->isChecked()) {
pastDays->setChecked(true);
pastXDays->setValue(20);
@ -99,39 +117,71 @@ void TabLog::getClicked()
}
if (maximumResults->value() == 0) {
maximumResults->setValue(1000);
maximumResults->setValue(200);
}
pageSize = qBound(1, maximumResults->value(), 1000);
int dateRange = 0;
if (lastHour->isChecked()) {
dateRange = 1;
}
if (today->isChecked()) {
dateRange = 24;
}
if (pastDays->isChecked()) {
dateRange = pastXDays->value() * 24;
}
Command_ViewLogHistory cmd;
cmd.set_user_name(findUsername->text().toStdString());
cmd.set_ip_address(findIPAddress->text().toStdString());
cmd.set_game_name(findGameName->text().toStdString());
cmd.set_game_id(findGameID->text().toStdString());
cmd.set_message(findMessage->text().toStdString());
// Build the command once, here, and let every page copy it: rebuilding it from the
// widgets per page would run page 2+ against filters edited after Get was clicked,
// appending rows selected by a different query under the first page's results.
currentQuery.Clear();
currentQuery.set_user_name(findUsername->text().toStdString());
currentQuery.set_ip_address(findIPAddress->text().toStdString());
currentQuery.set_game_name(findGameName->text().toStdString());
currentQuery.set_game_id(findGameID->text().toStdString());
currentQuery.set_message(findMessage->text().toStdString());
if (mainRoom->isChecked()) {
cmd.add_log_location("room");
};
currentQuery.add_log_location("room");
}
if (gameRoom->isChecked()) {
cmd.add_log_location("game");
};
currentQuery.add_log_location("game");
}
if (privateChat->isChecked()) {
cmd.add_log_location("chat");
};
cmd.set_date_range(dateRange);
cmd.set_maximum_results(maximumResults->value());
currentQuery.add_log_location("chat");
}
currentQuery.set_date_range(dateRangeForSelection());
currentQuery.set_maximum_results(pageSize);
hasCurrentQuery = true;
snapshotBefore.clear();
lastPageStartKey.clear();
// Fresh search: reset pagination state and clear previous pages.
currentOffset = 0;
roomCounter = 0;
gameCounter = 0;
chatCounter = 0;
roomTable->setRowCount(0);
gameTable->setRowCount(0);
chatTable->setRowCount(0);
fetchPage(0);
}
int TabLog::dateRangeForSelection() const
{
if (lastHour->isChecked()) {
return 1;
}
if (today->isChecked()) {
return 24;
}
if (pastDays->isChecked()) {
return pastXDays->value() * 24;
}
return 0;
}
void TabLog::fetchPage(int offset)
{
if (isFetching || !hasCurrentQuery) {
return;
}
Command_ViewLogHistory cmd = currentQuery;
cmd.set_offset(offset);
if (!snapshotBefore.isEmpty()) {
cmd.set_before_time(snapshotBefore.toStdString());
}
PendingCommand *pend;
if (canUseDeveloperCommands) {
@ -141,10 +191,42 @@ void TabLog::getClicked()
pend = client->prepareModeratorCommand(cmd);
}
isFetching = true;
getButton->setEnabled(false);
connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse);
client->sendCommand(pend);
}
void TabLog::checkFetchMore()
{
if (isFetching || !hasMoreResults) {
return;
}
// Only the table the moderator is looking at may pull the next page. Checking all
// three would make an empty or hidden table nearBottom by definition (maximum() and
// value() are both zero), so a room-only browse chained page after page without any
// scrolling - pulling the whole range with a growing OFFSET scan per page, and
// looping forever against a server that ignores `offset`.
auto *table = qobject_cast<QTableWidget *>(tabManager->currentWidget());
if (table == nullptr || table->rowCount() == 0) {
return;
}
// ScrollPerItem (the default) counts rows, not pixels: `value` is the top visible row
// and `maximum` the last one that can be scrolled to. Prefetch half a page ahead so
// scrolling never visibly stalls, without the fixed 300-row threshold firing the next
// page the moment a page loads.
const QScrollBar *bar = table->verticalScrollBar();
const int prefetchRows = qMax(1, pageSize / 2);
if (bar->maximum() - bar->value() > prefetchRows) {
return;
}
fetchPage(currentOffset);
}
void TabLog::clearClicked()
{
findUsername->clear();
@ -166,6 +248,10 @@ void TabLog::clearClicked()
pastDays->setAutoExclusive(true);
today->setAutoExclusive(true);
lastHour->setAutoExclusive(true);
currentOffset = 0;
hasMoreResults = false;
hasCurrentQuery = false;
snapshotBefore.clear();
}
void TabLog::createDock()
@ -209,14 +295,15 @@ void TabLog::createDock()
pastXDays = new QSpinBox;
pastXDays->setMaximum(20);
labelMaximum = new QLabel(tr("Maximum Results: "));
labelMaximum = new QLabel(tr("Results per page: "));
maximumResults = new QSpinBox;
maximumResults->setMaximum(1000);
maximumResults->setToolTip(tr("Page size for smooth scrolling: more rows load automatically as you scroll."));
labelDescription = new QLabel(tr(
"At least one filter is required.\nThe more information you put in, the more specific your results will be."));
labelDescription = new QLabel(tr("Leave filters empty to browse recent room and game messages.\n"
"More rows load automatically as you scroll."));
getButton = new QPushButton(tr("Get User Logs"));
getButton = new QPushButton(tr("Get Logs"));
getButton->setAutoDefault(true);
connect(getButton, &QPushButton::clicked, this, &TabLog::getClicked);
@ -262,7 +349,7 @@ void TabLog::createDock()
maxResultsGrid->addWidget(labelMaximum, 0, 0);
maxResultsGrid->addWidget(maximumResults, 0, 1);
maxResultsGroupBox = new QGroupBox(tr("Maximum Results"));
maxResultsGroupBox = new QGroupBox(tr("Results per page"));
maxResultsGroupBox->setLayout(maxResultsGrid);
descriptionGrid = new QGridLayout;
@ -295,84 +382,134 @@ void TabLog::createDock()
searchDock->setWidget(searchDockContents);
}
void TabLog::appendChatMessage(QTableWidget *table,
int &counter,
const QString &time,
const QString &senderName,
const QString &senderIp,
const QString &messageText,
const QString &targetId,
const QString &targetName,
const QString &channel)
{
table->insertRow(counter);
table->setItem(counter, 0, new QTableWidgetItem(time));
table->setItem(counter, 1, new QTableWidgetItem(senderName));
table->setItem(counter, 2, new QTableWidgetItem(senderIp));
table->setItem(counter, 3, new QTableWidgetItem(messageText));
table->setItem(counter, 4, new QTableWidgetItem(targetId));
table->setItem(counter, 5, new QTableWidgetItem(targetName));
if (table->columnCount() > 6) {
table->setItem(counter, 6, new QTableWidgetItem(channel));
}
++counter;
}
void TabLog::viewLogHistory_processResponse(const Response &resp)
{
const Response_ViewLogHistory &response = resp.GetExtension(Response_ViewLogHistory::ext);
const bool isFirstPage = (currentOffset == 0);
isFetching = false;
getButton->setEnabled(true);
if (resp.response_code() != Response::RespOk) {
QMessageBox::critical(static_cast<QWidget *>(parent()), tr("Message History"),
tr("Failed to collect message history information."));
return;
}
if (response.log_message_size() == 0) {
if (isFirstPage && response.log_message_size() == 0) {
QMessageBox::information(static_cast<QWidget *>(parent()), tr("Message History"),
tr("There are no messages for the selected filters."));
hasMoreResults = false;
return;
}
int roomCounter = 0, gameCounter = 0, chatCounter = 0;
roomTable->setRowCount(roomCounter);
gameTable->setRowCount(gameCounter);
chatTable->setRowCount(chatCounter);
const QString thisPageStart = response.log_message_size() > 0 ? pageStartKey(response) : QString();
if (isFirstPage) {
// Rows come newest first, so the first one fixes the snapshot every later page is
// bounded by. Without it, each message logged between two page fetches pushes the
// window down by one and repeats a row the moderator already has.
snapshotBefore = QString::fromStdString(response.log_message(0).time());
} else if (!response.has_has_more() && !thisPageStart.isEmpty() && thisPageStart == lastPageStartKey) {
// A server that predates `offset` ignores it and returns the same page again. It
// also predates `has_more`, so the full-page heuristic below can never prove
// exhaustion and every scroll would append the same rows forever. The identical
// first row is the proof; stop instead of looping.
hasMoreResults = false;
return;
}
lastPageStartKey = thisPageStart;
for (int i = 0; i < response.log_message_size(); ++i) {
ServerInfo_ChatMessage message = response.log_message(i);
const QString targetType = QString::fromStdString(message.target_type());
if (targetType == "room" || targetType == "room_channel") {
roomTable->insertRow(roomCounter);
roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
roomTable->setItem(roomCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip())));
roomTable->setItem(roomCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id())));
roomTable->setItem(roomCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel())));
++roomCounter;
appendChatMessage(roomTable, roomCounter, QString::fromStdString(message.time()),
QString::fromStdString(message.sender_name()),
QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()),
QString::fromStdString(message.target_id()),
QString::fromStdString(message.target_name()), QString::fromStdString(message.channel()));
}
if (targetType == "game") {
gameTable->insertRow(gameCounter);
gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
gameTable->setItem(gameCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip())));
gameTable->setItem(gameCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
gameTable->setItem(gameCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id())));
gameTable->setItem(gameCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
++gameCounter;
appendChatMessage(gameTable, gameCounter, QString::fromStdString(message.time()),
QString::fromStdString(message.sender_name()),
QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()),
QString::fromStdString(message.target_id()),
QString::fromStdString(message.target_name()));
}
if (targetType == "chat") {
chatTable->insertRow(chatCounter);
chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
chatTable->setItem(chatCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip())));
chatTable->setItem(chatCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
chatTable->setItem(chatCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id())));
chatTable->setItem(chatCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
++chatCounter;
appendChatMessage(chatTable, chatCounter, QString::fromStdString(message.time()),
QString::fromStdString(message.sender_name()),
QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()),
QString::fromStdString(message.target_id()),
QString::fromStdString(message.target_name()));
}
}
currentOffset += response.log_message_size();
if (response.has_has_more()) {
hasMoreResults = response.has_more();
} else {
// Legacy server without has_more: a full page implies more may exist.
hasMoreResults = (response.log_message_size() >= pageSize);
}
if (roomCounter) {
roomTable->show();
roomTable->resizeColumnsToContents();
if (isFirstPage) {
roomTable->resizeColumnsToContents();
}
} else {
roomTable->hide();
}
if (gameCounter) {
gameTable->resizeColumnsToContents();
if (isFirstPage) {
gameTable->resizeColumnsToContents();
}
gameTable->show();
} else {
gameTable->hide();
}
if (chatCounter) {
chatTable->resizeColumnsToContents();
if (isFirstPage) {
chatTable->resizeColumnsToContents();
}
chatTable->show();
} else {
chatTable->hide();
}
// The new rows may already fill less than a viewport (or the user is
// still parked at the bottom), so chain the next page without waiting
// for another scroll event. The prefetch stays invisible: rows simply
// keep appearing as you scroll.
checkFetchMore();
}
void TabLog::restartLayout()

View file

@ -10,6 +10,7 @@
#include "tab.h"
#include <QString>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <qtmetamacros.h>
class AbstractClient;
@ -25,6 +26,7 @@ class QWidget;
class QGridLayout;
class QVBoxLayout;
class QTableWidget;
class QTabWidget;
class Response;
class TabSupervisor;
@ -48,8 +50,37 @@ private:
*buttonGroupBox;
QVBoxLayout *mainLayout;
QTableWidget *roomTable, *gameTable, *chatTable;
QTabWidget *tabManager = nullptr;
//! The filters of the search in flight, captured when Get was clicked so that
//! later pages cannot pick up edits the moderator made after starting it.
Command_ViewLogHistory currentQuery;
bool hasCurrentQuery = false;
//! Newest `log_time` of the first page, sent back as the upper bound of every
//! later page so messages logged in the meantime do not shift the window.
QString snapshotBefore;
//! Identity of the previous page's first row, used to spot a server that ignores
//! `offset` and hands back the same page forever.
QString lastPageStartKey;
int currentOffset = 0;
int pageSize = 200;
int roomCounter = 0, gameCounter = 0, chatCounter = 0;
bool isFetching = false;
bool hasMoreResults = false;
void createDock();
[[nodiscard]] int dateRangeForSelection() const;
void fetchPage(int offset);
void appendChatMessage(QTableWidget *table,
int &counter,
const QString &time,
const QString &senderName,
const QString &senderIp,
const QString &messageText,
const QString &targetId,
const QString &targetName,
const QString &channel = QString());
void checkFetchMore();
signals:
private slots:

View file

@ -54,6 +54,7 @@
#include <QPainter>
#include <QRect>
#include <QSharedPointer>
#include <QStackedWidget>
#include <QStringLiteral>
#include <QStyle>
#include <QStyleOption>
@ -157,6 +158,15 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr),
tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false)
{
setObjectName("tabSupervisor");
// QTabWidget keeps tab pages in an internal stacked widget. Give it a stable
// name so the GUI tests can address the pages by a readable path
// (tabSupervisor/stackedWidget/<TabName>).
if (auto *stackedWidget = findChild<QStackedWidget *>()) {
stackedWidget->setObjectName("stackedWidget");
}
setElideMode(Qt::ElideRight);
setMovable(true);
setIconSize(QSize(15, 15));
@ -462,6 +472,9 @@ void TabSupervisor::addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager
auto closeSide = static_cast<QTabBar::ButtonPosition>(
tabBar()->style()->styleHint(QStyle::SH_TabBar_CloseButtonPosition, nullptr, tabBar()));
auto *closeButton = new CloseButton(tab);
// No object name: the tab bar index shifts when tabs move or close, so an
// index-derived name would go stale (or collide). Callers find the button
// through the tab widget instead.
if (manager) {
// If managed, all close requests should go through the menu action
connect(closeButton, &CloseButton::clicked, this, [manager] { checkAndTrigger(manager, false); });

View file

@ -0,0 +1,203 @@
#include "card_database_visual_view.h"
#include "../../card_picture_loader/card_picture_loader.h"
#include "../cards/card_info_picture_widget.h"
#include "../cards/card_picture_delegate.h"
#include "../cards/card_size_widget.h"
#include "card_database_visual_display_model.h"
#include "libcockatrice/card/printing/exact_card.h"
#include <QAbstractItemModel>
#include <QAbstractItemView>
#include <QApplication>
#include <QCursor>
#include <QEvent>
#include <QFrame>
#include <QModelIndex>
#include <QMouseEvent>
#include <QScrollBar>
#include <QSize>
#include <QWheelEvent>
#include <QWidget>
#include <QtGlobal>
#include <qnamespace.h>
CardDatabaseVisualView::CardDatabaseVisualView(CardDatabaseVisualDisplayModel *_model, QWidget *parent)
: QListView(parent), model(_model)
{
auto *cardDelegate = new CardPictureDelegate(model, this);
setItemDelegate(cardDelegate);
setModel(model);
setViewMode(QListView::IconMode);
setFlow(QListView::LeftToRight);
setWrapping(true);
setResizeMode(QListView::Adjust);
setMovement(QListView::Static);
setUniformItemSizes(true);
setLayoutMode(QListView::SinglePass);
setVerticalScrollMode(QAbstractItemView::ScrollPerPixel);
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
setFrameShape(QFrame::NoFrame);
setWordWrap(false);
// QListView hands the delegate's item height to the scroll bar as its single
// step under ScrollPerPixel, so one wheel notch would jump wheelScrollLines()
// whole card rows. Give the bar a text line instead; the wheel handler below
// scales that line to the grid. A positive step also stops Qt from taking the
// item height back on the next layout.
verticalScrollBar()->setSingleStep(qMax(1, fontMetrics().height()));
// The grid is a read-only surface: clicks are actions on a card, not a
// selection, and they must not steal focus from the search field.
setSelectionMode(QAbstractItemView::NoSelection);
setEditTriggers(QAbstractItemView::NoEditTriggers);
setFocusPolicy(Qt::NoFocus);
// Hover drives the card preview.
setMouseTracking(true);
viewport()->setMouseTracking(true);
connect(&CardPictureLoader::getInstance(), &CardPictureLoader::pixmapLoaded, this,
[this] { viewport()->update(); });
// A new search result starts at the top, like the widget-per-card grid did.
connect(model, &QAbstractItemModel::modelReset, this, [this] {
verticalScrollBar()->setValue(0);
// The rows under a stationary cursor changed with the reset: re-resolve the
// hover so the preview follows the grid, as enterEvent did per card widget.
hoveredRow = -1;
updateHoveredRow(viewport()->mapFromGlobal(QCursor::pos()));
});
setCardSizeFactor(cardSizeFactor);
}
void CardDatabaseVisualView::setCardSizeFactor(int factor)
{
cardSizeFactor = qBound(CardSizeWidget::MIN_CARD_SIZE, factor, CardSizeWidget::MAX_CARD_SIZE);
const QSize cardSize = CardInfoPictureWidget::sizeForScaleFactor(cardSizeFactor);
if (auto *cardDelegate = qobject_cast<CardPictureDelegate *>(itemDelegate())) {
cardDelegate->setCardSize(cardSize);
}
// The delegate's sizeHint does not emit sizeHintChanged, so the grid size is what
// makes a resize take effect. Keep the two in step.
setGridSize(cardSize + QSize(CardPictureDelegate::SPACING, CardPictureDelegate::SPACING));
}
void CardDatabaseVisualView::mousePressEvent(QMouseEvent *event)
{
const QModelIndex index = indexAt(event->pos());
if (index.isValid() && model != nullptr) {
const ExactCard card = model->cardAt(index.row());
if (card) {
emit cardClicked(event, card);
event->accept();
return;
}
}
QListView::mousePressEvent(event);
}
void CardDatabaseVisualView::mouseMoveEvent(QMouseEvent *event)
{
QListView::mouseMoveEvent(event);
updateHoveredRow(event->pos());
}
bool CardDatabaseVisualView::viewportEvent(QEvent *event)
{
// A per-card widget reported its hover from enterEvent, which also fires when the
// grid changes under a stationary cursor. A QListView only reports moves, so
// re-resolve the hover on enter and clear it on leave.
if (event->type() == QEvent::Enter) {
updateHoveredRow(viewport()->mapFromGlobal(QCursor::pos()));
} else if (event->type() == QEvent::Leave) {
hoveredRow = -1;
}
return QListView::viewportEvent(event);
}
void CardDatabaseVisualView::wheelEvent(QWheelEvent *event)
{
// Ctrl+wheel is the card-size gesture and is filtered out before it reaches
// the view; any other modifier keeps QAbstractSlider's own page scrolling.
if (event->modifiers() != Qt::NoModifier) {
QListView::wheelEvent(event);
return;
}
QScrollBar *bar = verticalScrollBar();
if (!bar->isVisible()) {
event->ignore();
return;
}
// Touchpads and high-resolution wheels already report pixels: follow them.
if (!event->pixelDelta().isNull()) {
bar->setValue(bar->value() - event->pixelDelta().y());
event->accept();
return;
}
if (event->angleDelta().y() == 0) {
QListView::wheelEvent(event);
return;
}
// Accumulate wheel deltas smaller than a notch, like QAbstractSlider does, so
// a fine-grained wheel still moves.
wheelRemainder += static_cast<qreal>(event->angleDelta().y()) / 120.0;
const int notches = static_cast<int>(wheelRemainder);
if (notches == 0) {
event->accept();
return;
}
wheelRemainder -= notches;
const int pixels = -notches * wheelStep();
bar->setValue(qBound(bar->minimum(), bar->value() + pixels, bar->maximum()));
event->accept();
}
int CardDatabaseVisualView::wheelStep() const
{
// A card row is far too coarse for one wheel line, and a text line is too
// fine, so a line is an eighth of the grid cell. A notch then moves about a
// third of a card row at any card size, and the OS line count still applies.
const int lineHeight = qMax(1, gridSize().height() / 8);
return qMax(1, QApplication::wheelScrollLines()) * lineHeight;
}
void CardDatabaseVisualView::updateHoveredRow(const QPoint &position)
{
// indexAt() also answers for points outside the viewport, so a cursor parked
// below the grid (on the size slider, say) would resolve to the row just under
// the visible area. Only a point inside the viewport can hover a card.
if (model == nullptr || !viewport()->rect().contains(position)) {
return;
}
const QModelIndex index = indexAt(position);
const int row = index.isValid() ? index.row() : -1;
if (row == hoveredRow) {
return;
}
hoveredRow = row;
if (row >= 0) {
const ExactCard card = model->cardAt(row);
if (card) {
emit cardHovered(card);
}
}
}
void CardDatabaseVisualView::leaveEvent(QEvent *event)
{
hoveredRow = -1;
QListView::leaveEvent(event);
}

View file

@ -0,0 +1,67 @@
/**
* @file card_database_visual_view.h
* @ingroup VisualCardDatabaseWidgets
* @brief Icon-mode view of the card database, one card picture per item.
*/
#ifndef CARD_DATABASE_VISUAL_VIEW_H
#define CARD_DATABASE_VISUAL_VIEW_H
#include <QListView>
#include <QSize>
#include <qtmetamacros.h>
class CardDatabaseVisualDisplayModel;
class ExactCard;
class QEvent;
class QMouseEvent;
class QPoint;
class QWheelEvent;
/**
* @brief A wrapping grid of card pictures.
*
* The view virtualizes painting: it only asks the delegate for the items that are
* on screen, so the whole card database can sit in the model without a widget (or
* pixmap) per card. Clicks and hover are reported with the exact card under the
* cursor, which is what the deck editor needs to add, decrement or preview a card.
*/
class CardDatabaseVisualView : public QListView
{
Q_OBJECT
public:
explicit CardDatabaseVisualView(CardDatabaseVisualDisplayModel *_model, QWidget *parent = nullptr);
/**
* @brief Sets the card size as a percentage of the base card width.
* @param factor Percentage in the range of the card size slider.
*/
void setCardSizeFactor(int factor);
signals:
/** @brief A card was pressed. The event is valid for the duration of the emission. */
void cardClicked(QMouseEvent *event, const ExactCard &card);
/** @brief The cursor moved onto a different card. */
void cardHovered(const ExactCard &card);
protected:
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void leaveEvent(QEvent *event) override;
bool viewportEvent(QEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
private:
void updateHoveredRow(const QPoint &position);
/// Pixels one wheel notch moves, scaled to the grid and the OS line count.
[[nodiscard]] int wheelStep() const;
CardDatabaseVisualDisplayModel *model;
/// Fraction of a line left over from a wheel event smaller than one notch.
qreal wheelRemainder = 0.0;
int cardSizeFactor = 100;
int hoveredRow = -1;
};
#endif // CARD_DATABASE_VISUAL_VIEW_H

View file

@ -5,40 +5,29 @@
#include "../../../filters/syntax_help.h"
#include "../../key_signals.h"
#include "../../pixel_map_generator.h"
#include "../cards/card_info_picture_widget.h"
#include "../cards/card_info_picture_with_text_overlay_widget.h"
#include "../cards/card_size_widget.h"
#include "../deck_editor/card_database_view.h"
#include "../general/layout_containers/flow_widget.h"
#include "../utility/custom_line_edit.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "card_database_visual_display_model.h"
#include "card_database_visual_view.h"
#include "deck_list_model.h"
#include "libcockatrice/card/card_localization.h"
#include "libcockatrice/card/database/card_database_loader.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/card/printing/printing_info.h"
#include "libcockatrice/filters/filter_card.h"
#include "libcockatrice/filters/filter_tree.h"
#include "visual_database_display_color_filter_widget.h"
#include "visual_database_display_filter_toolbar_widget.h"
#include <QAbstractItemModel>
#include <QAction>
#include <QDebug>
#include <QIcon>
#include <QLabel>
#include <QLineEdit>
#include <QList>
#include <QMessageLogger>
#include <QModelIndex>
#include <QPair>
#include <QPersistentModelIndex>
#include <QPushButton>
#include <QScrollArea>
#include <QScrollBar>
#include <QSharedPointer>
#include <QSizePolicy>
#include <QSlider>
#include <QStringLiteral>
@ -48,14 +37,11 @@
#include <QVBoxLayout>
#include <QVariant>
#include <QtGlobal>
#include <algorithm>
#include <libcockatrice/card/card_info_comparator.h>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/utility/qt_utils.h>
#include <utility>
class QMouseEvent;
@ -64,11 +50,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
DeckListModel *deckListModel)
: QWidget(parent)
{
debounceTimer = new QTimer(this);
debounceTimer->setSingleShot(true); // Ensure it only fires once after the timeout
connect(debounceTimer, &QTimer::timeout, this, &VisualDatabaseDisplayWidget::onSearchModelChanged);
// Create display model
databaseDisplayModel = new CardDatabaseDisplayModel(this);
databaseDisplayModel->setObjectName("databaseDisplayModel");
@ -86,23 +67,12 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
applyCardSearchLanguage);
cards = new QList<ExactCard>;
connect(databaseDisplayModel, &CardDatabaseDisplayModel::modelDirty, this,
&VisualDatabaseDisplayWidget::modelDirty);
// Set up main layout and widgets
setMinimumSize(0, 0);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
mainLayout = QtUtils::makeLayout<QVBoxLayout>(this, 0, 1);
setLayout(mainLayout);
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarPolicy::ScrollBarAsNeeded);
cardSizeWidget = new CardSizeWidget(this, flowWidget,
SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize());
connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setVisualDatabaseDisplayCardSize);
cardSizeWidget->enableCtrlScrollResize(flowWidget);
searchContainer = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff);
searchEdit = new SearchLineEdit();
@ -159,6 +129,25 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
connect(databaseView, &CardDatabaseView::relatedCardClicked, this,
&VisualDatabaseDisplayWidget::onRelatedCardClicked);
// The visual grid is a real view on a flattened model: one row per displayed
// card picture, painted on demand.
visualDisplayModel = new CardDatabaseVisualDisplayModel(databaseDisplayModel, CardDatabaseManager::query(), this);
cardView = new CardDatabaseVisualView(visualDisplayModel, this);
cardView->setObjectName("visualCardView");
connect(cardView, &CardDatabaseVisualView::cardClicked, this,
&VisualDatabaseDisplayWidget::cardClickedDatabaseDisplay);
connect(cardView, &CardDatabaseVisualView::cardHovered, this,
&VisualDatabaseDisplayWidget::cardHoveredDatabaseDisplay);
const int cardSize = SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize();
cardSizeWidget = new CardSizeWidget(this, nullptr, cardSize);
connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setVisualDatabaseDisplayCardSize);
connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, cardView, &CardDatabaseVisualView::setCardSizeFactor);
cardView->setCardSizeFactor(cardSize);
cardSizeWidget->enableCtrlScrollResize(cardView);
colorFilterWidget = new VisualDatabaseDisplayColorFilterWidget(this, filterModel);
filterContainer = new VisualDatabaseDisplayFilterToolbarWidget(this, deckListModel);
@ -190,8 +179,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
databaseLoadIndicator->setVisible(false);
}
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
connect(scrollBar, &QScrollBar::valueChanged, [this](int /*value*/) { loadCurrentPage(); });
retranslateUi();
}
@ -210,7 +197,7 @@ void VisualDatabaseDisplayWidget::initialize()
mainLayout->addWidget(databaseView);
mainLayout->addWidget(flowWidget);
mainLayout->addWidget(cardView);
mainLayout->addWidget(cardSizeWidget);
@ -241,8 +228,6 @@ void VisualDatabaseDisplayWidget::initializeFilters()
databaseDisplayModel->setFilterTree(filterModel->filterTree());
QTimer::singleShot(5000, this, [this] { loadCurrentPage(); });
retranslateUi();
});
}
@ -278,71 +263,36 @@ void VisualDatabaseDisplayWidget::updateDisplayModeButton()
displayModeButton->setText(displayModeButton->isChecked() ? tr("Table") : tr("Visual"));
}
void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
loadCurrentPage();
}
void VisualDatabaseDisplayWidget::onDisplayModeChanged(bool checked)
{
updateDisplayModeButton();
if (checked) {
// Table mode
flowWidget->setVisible(false);
cardView->setVisible(false);
cardSizeWidget->setVisible(false);
databaseView->setItemDelegate(new QStyledItemDelegate(databaseView));
databaseView->setVisible(true);
} else {
// Visual mode
flowWidget->setVisible(true);
cardView->setVisible(true);
cardSizeWidget->setVisible(true);
databaseView->setVisible(false);
populateCards();
}
}
void VisualDatabaseDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card)
{
emit cardClickedDatabaseDisplay(event, card);
}
void VisualDatabaseDisplayWidget::onHover(const ExactCard &hoveredCard)
{
emit cardHoveredDatabaseDisplay(hoveredCard);
}
void VisualDatabaseDisplayWidget::addCardToDisplay(const ExactCard &cardToAdd)
{
cards->append(cardToAdd);
auto *display = new CardInfoPictureWithTextOverlayWidget(flowWidget, false);
display->setScaleFactor(cardSizeWidget->getSlider()->value());
display->setCard(cardToAdd);
flowWidget->addWidget(display);
connect(display, &CardInfoPictureWithTextOverlayWidget::cardClicked, this, &VisualDatabaseDisplayWidget::onClick);
connect(display, &CardInfoPictureWithTextOverlayWidget::hoveredOnCard, this, &VisualDatabaseDisplayWidget::onHover);
connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, display, &CardInfoPictureWidget::setScaleFactor);
}
bool VisualDatabaseDisplayWidget::isVisualDisplayMode() const
{
return !displayModeButton->isChecked();
}
QStringList VisualDatabaseDisplayWidget::collectSetFilterTerms() const
{
return filterModel->getFilterTermsOfType(CardFilter::AttrSet);
}
void VisualDatabaseDisplayWidget::onSearchModelChanged()
{
if (flowWidget->isVisible()) {
// Clear the current page and prepare for new data
flowWidget->clearLayout(); // Clear existing cards
cards->clear(); // Clear the card list
// Reset scrollbar position to the top after loading new cards
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
scrollBar->setValue(0); // Reset scrollbar to top
currentPage = 0;
loadCurrentPage();
qCDebug(VisualDatabaseDisplayLog) << "Search model changed";
}
visualDisplayModel->setSetFilterTerms(collectSetFilterTerms());
}
void VisualDatabaseDisplayWidget::onSelectedCardChanged(const QString &cardName)
@ -368,113 +318,3 @@ void VisualDatabaseDisplayWidget::onRelatedCardClicked(const QString &relatedCar
ExactCard exactCard = CardDatabaseManager::query()->guessCard({relatedCard});
emit cardInfoRequested(exactCard);
}
bool VisualDatabaseDisplayWidget::nearEndOfPage() const
{
if (!flowWidget->isVisible()) {
return false;
}
QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar();
if (scrollBar->value() + scrollBar->pageStep() * 2 < scrollBar->maximum()) {
return false; // there is at least two pages of space to scroll remaining
}
return true;
}
void VisualDatabaseDisplayWidget::loadCurrentPage()
{
// Ensure only the initial page is loaded
if (currentPage == 0) {
if (!initialLoadScheduled) {
initialLoadScheduled = true;
qCDebug(VisualDatabaseDisplayLog) << "Loading the first page";
// Defer the first page so the tab switch stays responsive. The card
// grid builds one event loop turn later. This also applies to
// search-driven reloads, which reset currentPage back to 0.
QTimer::singleShot(0, this, [this] {
initialLoadScheduled = false;
populateCards();
});
}
} else if (nearEndOfPage()) {
// If not the first page, just load the next page and append to the flow widget
loadNextPage();
}
}
void VisualDatabaseDisplayWidget::populateCards()
{
cards->clear();
loadNextPage();
}
void VisualDatabaseDisplayWidget::loadNextPage()
{
// Calculate the start and end indices for the next page
int rowCount = databaseDisplayModel->rowCount();
int start = currentPage * cardsPerPage;
int end = qMin(start + cardsPerPage, rowCount);
// Load more cards if we are at the end of the current list and can fetch more
if (end >= rowCount && databaseDisplayModel->canFetchMore(QModelIndex())) {
databaseDisplayModel->fetchMore(QModelIndex());
}
// Load the next page of cards and add them to the flow widget
flowWidget->setUpdatesEnabled(false);
loadPage(start, end);
flowWidget->setUpdatesEnabled(true);
}
void VisualDatabaseDisplayWidget::loadPage(int start, int end)
{
QList<const CardFilter *> setFilters = filterModel->getFiltersOfType(CardFilter::AttrSet);
for (int row = start; row < end; ++row) {
qCDebug(VisualDatabaseDisplayLog) << "Adding " << row;
QModelIndex index = databaseDisplayModel->index(row, CardDatabaseModel::NameColumn);
QVariant name = databaseDisplayModel->data(index, Qt::DisplayRole);
qCDebug(VisualDatabaseDisplayLog) << name.toString();
if (CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(name.toString())) {
if (!setFilters.empty()) {
const auto &setMap = info->getSets();
for (const CardFilter *setFilter : setFilters) {
const auto it = setMap.constFind(setFilter->term());
if (it != setMap.constEnd()) {
for (const PrintingInfo &printing : it.value()) {
addCardToDisplay(ExactCard(info, printing));
}
}
}
} else {
addCardToDisplay(CardDatabaseManager::query()->getPreferredCard(info));
}
} else {
qCDebug(VisualDatabaseDisplayLog) << "Card not found in database!";
}
}
currentPage++;
}
void VisualDatabaseDisplayWidget::modelDirty() const
{
debounceTimer->start(debounceTime);
}
void VisualDatabaseDisplayWidget::sortCardList(const QStringList &properties,
Qt::SortOrder order = Qt::AscendingOrder) const
{
CardInfoComparator comparator(properties, order);
std::sort(cards->begin(), cards->end(), [comparator](const ExactCard &a, const ExactCard &b) {
return comparator(a.getCardPtr(), b.getCardPtr());
});
}
void VisualDatabaseDisplayWidget::databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight)
{
(void)topLeft;
(void)bottomRight;
qCDebug(VisualDatabaseDisplayLog) << "Database Data changed";
}

View file

@ -9,8 +9,6 @@
#include "libcockatrice/card/card_info.h"
#include <QLoggingCategory>
#include <QMouseEvent>
#include <QString>
#include <QStringList>
#include <QWidget>
@ -21,26 +19,21 @@
class CardDatabaseDisplayModel;
class CardDatabaseModel;
class CardDatabaseView;
class CardDatabaseVisualDisplayModel;
class CardDatabaseVisualView;
class CardSizeWidget;
class DeckList;
class DeckListModel;
class ExactCard;
class FilterTreeModel;
class FlowWidget;
class OverlapControlWidget;
class QLabel;
class QModelIndex;
class QMouseEvent;
class QPushButton;
class QScrollArea;
class QTimer;
class QToolButton;
class QVBoxLayout;
class SearchLineEdit;
class VisualDatabaseDisplayColorFilterWidget;
class VisualDatabaseDisplayFilterToolbarWidget;
template <typename T> class QList;
inline Q_LOGGING_CATEGORY(VisualDatabaseDisplayLog, "visual_database_display");
class VisualDatabaseDisplayWidget : public QWidget
{
@ -52,14 +45,6 @@ public:
DeckListModel *deckListModel = nullptr);
void retranslateUi();
void adjustCardsPerPage();
void populateCards();
void loadPage(int start, int end);
void loadNextPage();
void loadCurrentPage();
void sortCardList(const QStringList &properties, Qt::SortOrder order) const;
void setDeckList(const DeckList &new_deck_list_model);
/**
* @brief Sets the callback used to create a custom zone from the add-to-zone menu.
* The callback returns the name of the created zone, or an empty string if creation was cancelled.
@ -87,6 +72,13 @@ public:
bool isVisualDisplayMode() const;
public slots:
/**
* @brief Pushes the active set filters to the visual grid.
*
* The grid's rows follow the display model's filter and sort signals on their
* own; this only forwards the set filters, which decide which printings the
* grid shows.
*/
void onSearchModelChanged();
signals:
@ -101,11 +93,6 @@ signals:
protected slots:
void initialize();
void onClick(QMouseEvent *event, const ExactCard &card);
void onHover(const ExactCard &hoveredCard);
void addCardToDisplay(const ExactCard &cardToAdd);
void databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight);
void modelDirty() const;
void onDisplayModeChanged(bool checked);
void onSelectedCardChanged(const QString &cardName);
@ -125,31 +112,21 @@ private:
QToolButton *clearFilterWidget;
VisualDatabaseDisplayFilterToolbarWidget *filterContainer;
CardDatabaseDisplayModel *databaseDisplayModel;
CardDatabaseVisualDisplayModel *visualDisplayModel;
CardDatabaseView *databaseView;
CardDatabaseVisualView *cardView;
std::function<QString()> newZoneCreator;
QList<ExactCard> *cards;
QVBoxLayout *mainLayout;
QScrollArea *scrollArea;
FlowWidget *flowWidget;
QWidget *overlapCategories;
QVBoxLayout *overlapCategoriesLayout;
OverlapControlWidget *overlapControlWidget;
CardSizeWidget *cardSizeWidget;
QTimer *debounceTimer;
int debounceTime = 300; // in Ms
int currentPage = 0; // Current page index
int cardsPerPage = 100; // Number of cards per page
bool filtersInitialized = false;
bool initialLoadScheduled = false;
void initializeFilters();
void highlightAllSearchEdit();
void updateDisplayModeButton();
bool nearEndOfPage() const;
[[nodiscard]] QStringList collectSetFilterTerms() const;
protected:
void resizeEvent(QResizeEvent *event) override;
void showEvent(QShowEvent *event) override;
};

View file

@ -55,6 +55,13 @@
#include "widgets/server/handle_public_servers.h"
#include "widgets/settings_page/abstract_settings_page.h"
#include "widgets/tabs/api/commander_spellbook/handle_commander_brackets.h"
#ifdef WITH_GUI_TEST
#include "pqEventObserver.h"
#include "pqEventSource.h"
#include "pqEventTypes.h"
#include "pqTestUtility.h"
#include "recording.h"
#endif
#include <QAction>
#include <QApplication>
@ -254,7 +261,11 @@ void MainWindow::actWatchReplay()
void MainWindow::localGameEnded()
{
delete localServer;
// Defer deletion so the LocalClients (already scheduled with deleteLater() in TabSupervisor::stop)
// and any queued commands are processed before the server and its interfaces are destroyed.
if (localServer) {
localServer->deleteLater();
}
localServer = nullptr;
aConnect->setEnabled(true);
@ -306,6 +317,7 @@ void MainWindow::actAbout()
mb.setIconPixmap(
themePixmap(QStringLiteral("cockatrice")).scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation));
mb.setTextInteractionFlags(Qt::TextBrowserInteraction);
mb.setObjectName("aboutDialog");
mb.exec();
}
@ -344,6 +356,24 @@ void MainWindow::actOpenSettingsFolder()
QtUtils::openPathInFileManager(dir);
}
#ifdef WITH_GUI_TEST
void MainWindow::actRecord()
{
QString filename = QFileDialog::getSaveFileName(this, tr("Test File Name"), QString(), tr("XML Files (*.xml)"));
if (!filename.isEmpty()) {
testUtility->recordTests(filename);
}
}
void MainWindow::actPlayRecording()
{
QString filename = QFileDialog::getOpenFileName(this, tr("Test File Name"), QString(), tr("XML Files (*.xml)"));
if (!filename.isEmpty()) {
testUtility->playTests(filename);
}
}
#endif
void MainWindow::retranslateUi()
{
connectionController->refreshWindowTitle();
@ -388,6 +418,10 @@ void MainWindow::retranslateUi()
aViewLog->setText(tr("View &Debug Log"));
aOpenSettingsFolder->setText(tr("Open Settings Folder"));
aFirstRunWizard->setText(tr("Re-run Onboarding Wizard..."));
#ifdef WITH_GUI_TEST
aRecord->setText(tr("&Record Test..."));
aPlayRecording->setText(tr("&Play Test..."));
#endif
aShow->setText(tr("Show/Hide"));
@ -397,24 +431,33 @@ void MainWindow::retranslateUi()
void MainWindow::createActions()
{
aConnect = new QAction(this);
aConnect->setObjectName("actionConnect");
connect(aConnect, &QAction::triggered, this, &MainWindow::actConnect);
aDisconnect = new QAction(this);
aDisconnect->setObjectName("actionDisconnect");
aDisconnect->setEnabled(false);
connect(aDisconnect, &QAction::triggered, this, &MainWindow::actDisconnect);
aSinglePlayer = new QAction(this);
aSinglePlayer->setObjectName("actionSinglePlayer");
connect(aSinglePlayer, &QAction::triggered, this, &MainWindow::actSinglePlayer);
aWatchReplay = new QAction(this);
aWatchReplay->setObjectName("actionWatchReplay");
connect(aWatchReplay, &QAction::triggered, this, &MainWindow::actWatchReplay);
aFullScreen = new QAction(this);
aFullScreen->setObjectName("actionFullScreen");
aFullScreen->setCheckable(true);
connect(aFullScreen, &QAction::toggled, this, &MainWindow::actFullScreen);
aRegister = new QAction(this);
aRegister->setObjectName("actionRegister");
connect(aRegister, &QAction::triggered, connectionController, &ConnectionController::registerToServer);
aForgotPassword = new QAction(this);
aForgotPassword->setObjectName("actionForgotPassword");
connect(aForgotPassword, &QAction::triggered, connectionController, &ConnectionController::forgotPasswordRequest);
aSettings = new QAction(this);
aSettings->setObjectName("actionSettings");
connect(aSettings, &QAction::triggered, this, &MainWindow::actSettings);
aExit = new QAction(this);
aExit->setObjectName("actionExit");
connect(aExit, &QAction::triggered, this, &MainWindow::actExit);
aManageSets = new QAction(QString(), this);
@ -451,6 +494,12 @@ void MainWindow::createActions()
connect(aOpenSettingsFolder, &QAction::triggered, this, &MainWindow::actOpenSettingsFolder);
aFirstRunWizard = new QAction(this);
connect(aFirstRunWizard, &QAction::triggered, this, [this] { runFirstRunWizard(); });
#ifdef WITH_GUI_TEST
aRecord = new QAction(this);
connect(aRecord, &QAction::triggered, this, &MainWindow::actRecord);
aPlayRecording = new QAction(this);
connect(aPlayRecording, &QAction::triggered, this, &MainWindow::actPlayRecording);
#endif
aShow = new QAction(this);
connect(aShow, &QAction::triggered, this, &MainWindow::actShow);
@ -491,7 +540,9 @@ void MainWindow::createActions()
void MainWindow::createMenus()
{
menuBar()->setObjectName("menubar");
cockatriceMenu = menuBar()->addMenu(QString());
cockatriceMenu->setObjectName("cockatriceMenu");
cockatriceMenu->addAction(aConnect);
cockatriceMenu->addAction(aDisconnect);
cockatriceMenu->addAction(aRegister);
@ -507,6 +558,7 @@ void MainWindow::createMenus()
cockatriceMenu->addAction(aExit);
dbMenu = menuBar()->addMenu(QString());
dbMenu->setObjectName("dbMenu");
dbMenu->addAction(aManageSets);
dbMenu->addAction(aEditTokens);
dbMenu->addSeparator();
@ -517,8 +569,10 @@ void MainWindow::createMenus()
dbMenu->addAction(aReloadCardDatabase);
tabsMenu = menuBar()->addMenu(QString());
tabsMenu->setObjectName("tabsMenu");
helpMenu = menuBar()->addMenu(QString());
helpMenu->setObjectName("helpMenu");
helpMenu->addAction(aAbout);
helpMenu->addAction(aTips);
helpMenu->addSeparator();
@ -531,12 +585,19 @@ void MainWindow::createMenus()
helpMenu->addAction(aOpenSettingsFolder);
helpMenu->addSeparator();
helpMenu->addAction(aFirstRunWizard);
#ifdef WITH_GUI_TEST
helpMenu->addSeparator();
helpMenu->addAction(aRecord);
helpMenu->addAction(aPlayRecording);
#endif
}
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false),
: QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false), noConnect(false),
cardUpdateProcess(nullptr), logviewDialog(nullptr)
{
setObjectName("MainWindow");
connect(&SettingsCache::instance().cacheStorage(), &CacheStorageSettings::pixmapCacheSizeChanged, this,
&MainWindow::pixmapCacheSizeChanged);
pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize());
@ -622,6 +683,13 @@ MainWindow::MainWindow(QWidget *parent)
// run startup check async
QTimer::singleShot(0, this, &MainWindow::startupConfigCheck);
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
#ifdef WITH_GUI_TEST
testUtility = new pqTestUtility(this);
testUtility->setRecordWithDialog(true);
testUtility->addEventObserver("xml", new XMLEventObserver(this));
testUtility->addEventSource("xml", new XMLEventSource(this));
#endif
}
void MainWindow::startupConfigCheck()
@ -776,6 +844,11 @@ void MainWindow::runFirstRunWizard(bool firstRun)
*/
void MainWindow::applyStartupDestination()
{
if (noConnect) {
qCInfo(WindowMainStartupAutoconnectLog) << "Startup destination suppressed (--no-connect)";
return;
}
// An explicit command-line connect takes precedence over the startup destination.
if (!connectTo.isEmpty()) {
return;
@ -1045,6 +1118,10 @@ void MainWindow::handleCockatriceLink(const QString &url)
void MainWindow::attemptStartupAutoConnect()
{
if (noConnect) {
qCInfo(WindowMainStartupAutoconnectLog) << "Auto-connect suppressed (--no-connect)";
return;
}
if (startupAutoConnectAttempted || skipStartupAutoConnect) {
return;
}

View file

@ -48,6 +48,7 @@
class QAction;
class QToolButton;
class QWidget;
class pqTestUtility;
struct LocalGameOptions;
inline Q_LOGGING_CATEGORY(WindowMainLog, "window_main");
@ -104,6 +105,11 @@ private slots:
void actUpdate();
void actViewLog();
void actOpenSettingsFolder();
#ifdef WITH_GUI_TEST
void actRecord();
void actPlayRecording();
#endif
void actShow();
void showWindowIfHidden();
void onUrlChainFinished(bool connected);
@ -185,6 +191,9 @@ private:
*aReloadCardDatabase;
QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aFirstRunWizard, *aStatusBar,
*aViewLog, *aOpenSettingsFolder;
#ifdef WITH_GUI_TEST
QAction *aRecord, *aPlayRecording;
#endif
TabSupervisor *tabSupervisor;
IntentUrlParser *urlParser;
@ -208,6 +217,7 @@ private:
bool skipStartupAutoConnect = false;
bool startupAutoConnectAttempted = false;
bool firstRunWizardActive = false;
bool noConnect = false; ///< --no-connect: skip every startup connection
QProcess *cardUpdateProcess;
QByteArray cardUpdateOutputBuffer;
QByteArray cardUpdateErrorOutput; ///< diagnostic output collected from the updater's stderr
@ -219,6 +229,9 @@ private:
GameReplay *replay;
DlgTipOfTheDay *tip;
QUrl connectTo;
#ifdef WITH_GUI_TEST
pqTestUtility *testUtility;
#endif
public:
explicit MainWindow(QWidget *parent = nullptr);
@ -226,6 +239,16 @@ public:
{
connectTo = QUrl(QString("cockatrice://%1").arg(url));
}
/**
* @brief Suppresses every startup connection: --connect, auto-connect and the
* configured startup destination. Used by the GUI test harness so a
* playback run never dials out.
*/
void setNoConnect(bool value)
{
noConnect = value;
}
// When set, the window's own startup connection (--connect or auto-connect
// on first activation) is skipped. Used for activation launches: the intent
// chain triggered by a cockatrice:// URL owns the connection, and letting
@ -266,6 +289,13 @@ public:
*/
void requestInstallerClose();
#ifdef WITH_GUI_TEST
pqTestUtility *getTestUtility() const
{
return testUtility;
}
#endif
protected:
void closeEvent(QCloseEvent *event) override;
void changeEvent(QEvent *event) override;

View file

@ -39,6 +39,10 @@
#include "single_instance_manager.h"
#include "version_string.h"
#ifdef WITH_GUI_TEST
#include "pqTestUtility.h"
#endif
#include <QApplication>
#include <QByteArray>
#include <QByteArrayView>
@ -62,10 +66,12 @@
#include <QString>
#include <QStringList>
#include <QStringLiteral>
#include <QThread>
#include <QTranslator>
#include <QUrl>
#include <QtGlobal>
#include <algorithm>
#include <cstdlib>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/rng/rng_sfmt.h>
#include <libcockatrice/settings/card_database_settings.h>
@ -343,9 +349,16 @@ int main(int argc, char *argv[])
parser.addOptions(
{{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"},
{{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")},
{"simulate-crash",
QCoreApplication::translate("main",
"Show the crash-report dialog (saves a mock file when no DSN is configured)")}});
{"simulate-crash", QCoreApplication::translate(
"main", "Show the crash-report dialog (saves a mock file when no DSN is configured)")},
{"no-connect", QCoreApplication::translate("main", "Do not connect to any server on startup")}
#ifdef WITH_GUI_TEST
,
{{"t", "test"},
QCoreApplication::translate("main", "Play back a QtTesting XML test file and exit"),
"test-file.xml"}
#endif
});
parser.process(app);
@ -483,6 +496,9 @@ int main(int argc, char *argv[])
if (parser.isSet("connect")) {
ui.setConnectTo(parser.value("connect"));
}
if (parser.isSet("no-connect")) {
ui.setNoConnect(true);
}
qCInfo(MainLog) << "MainWindow constructor finished";
ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice")));
@ -523,6 +539,27 @@ int main(int argc, char *argv[])
handleActivation(file);
}
#ifdef WITH_GUI_TEST
if (parser.isSet("test")) {
QString testFile = parser.value("test");
qCInfo(MainLog) << "Playing test file:" << testFile;
QTimer::singleShot(0, &ui, [&ui, testFile]() {
bool success = ui.getTestUtility()->playTests(testFile);
qCInfo(MainLog) << "Test playback" << (success ? "succeeded" : "FAILED");
qApp->exit(success ? 0 : 1);
// An event that fails while a modal dialog is open aborts the dispatcher,
// but the dialog's nested event loop can then keep the application alive
// forever. Hard-exit after a grace period so a red test always terminates.
auto *watchdog = QThread::create([success] {
QThread::sleep(10);
::_Exit(success ? 0 : 1);
});
watchdog->start();
});
}
#endif
// Connect to future file/URL events from other instances
QObject::connect(&instance, &SingleInstanceManager::filesReceived, [&handleActivation](const QStringList &files) {
for (const QString &file : files) {

1
external/qttesting vendored Submodule

@ -0,0 +1 @@
Subproject commit c7bb5d372133e94c75cbdc70b04e039b864ea379

View file

@ -33,6 +33,7 @@ cmakefile="CMakeLists.txt"
cmakedir="cmake/.*\\.cmake"
cmakeinclude=("cmake/gtest-CMakeLists.txt.in")
scripts="*.sh"
scriptsrx="[^/]*\\.sh$"
color="--"
verbosity=0
sep="----------"
@ -254,7 +255,7 @@ if [[ $branch ]]; then
if [[ $do_shell ]]; then
shell_names=()
for name in "${basenames[@]}"; do
filerx="(^|/)$scripts$"
filerx="(^|/)$scriptsrx"
if [[ $name =~ $filerx ]]; then
shell_names+=("$name")
fi

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
libcockatrice/card/card_info.h
libcockatrice/card/card_info_comparator.h

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
libcockatrice/deck_list/tree/abstract_deck_list_card_node.h
libcockatrice/deck_list/tree/abstract_deck_list_node.h

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_string.h
libcockatrice/filters/filter_tree.h
)

View file

@ -54,7 +54,7 @@ private:
enum Attr a;
public:
CardFilter(QString &term, Type type, Attr attr) : trm(term), t(type), a(attr)
CardFilter(const QString &term, Type type, Attr attr) : trm(term), t(type), a(attr)
{
}

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
libcockatrice/game/abstract_game.h
libcockatrice/game/board/arrow_data.h

View file

@ -192,12 +192,14 @@ void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone)
writeableRevealZone = _writeableRevealZone;
}
PendingCommand *ZoneViewZoneLogic::requestZoneDump()
PendingCommand *ZoneViewZoneLogic::prepareZoneDump()
{
PlayerActions *actions = getPlayer()->getPlayerActions();
PendingCommand *pend = actions->prepareZoneDump(this, numberCards);
if (pend != nullptr) {
actions->sendGameCommand(pend);
}
return pend;
return getPlayer()->getPlayerActions()->prepareZoneDump(this, numberCards);
}
void ZoneViewZoneLogic::sendZoneDump(PendingCommand *pendingCommand)
{
if (pendingCommand != nullptr) {
getPlayer()->getPlayerActions()->sendGameCommand(pendingCommand);
}
}

View file

@ -88,14 +88,21 @@ public:
}
/**
* @brief Prepares and sends the zone-dump command for this view.
* @brief Builds the zone-dump command for this view without sending it.
* @return The pending command, or nullptr when there is no zone to dump.
*
* Keeps command construction in game/ so graphics views don't reach
* through the player into PlayerActions to set it up. The command is sent
* asynchronously, so callers connect to finished() after calling this.
* through the player into PlayerActions to set it up. The response can
* arrive synchronously (local games), so callers must connect to
* PendingCommand::finished before calling sendZoneDump().
*/
PendingCommand *requestZoneDump();
PendingCommand *prepareZoneDump();
/**
* @brief Sends a command returned by prepareZoneDump().
* @param pendingCommand The prepared command; nullptr is ignored.
*/
void sendZoneDump(PendingCommand *pendingCommand);
protected:
void addCardImpl(CardState *card, int x, int y) override;

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
libcockatrice/interfaces/interface_cache_storage_settings_provider.h
libcockatrice/interfaces/interface_card_database_path_provider.h

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
add_subdirectory(libcockatrice/models/database)
add_subdirectory(libcockatrice/models/deck_list)

View file

@ -1,10 +1,7 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
card_database_model.h
card_database_display_model.h
card_database_visual_display_model.h
card/card_completer_proxy_model.h
card/card_search_model.h
card_set/card_sets_model.h
@ -17,6 +14,7 @@ add_library(
${HEADERS}
card_database_model.cpp
card_database_display_model.cpp
card_database_visual_display_model.cpp
card/card_completer_proxy_model.cpp
card/card_search_model.cpp
card_set/card_sets_model.cpp

View file

@ -21,7 +21,7 @@
class QObject;
CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent)
: QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr),
: QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr), cardModel(nullptr),
collator(NaturalSort::makeNaturalSortCollator())
{
filterTree = nullptr;
@ -33,22 +33,37 @@ CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent)
invalidate();
emit modelDirty();
});
loadedRowCount = 0;
}
void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model)
{
cardModel = qobject_cast<CardDatabaseModel *>(model);
QSortFilterProxyModel::setSourceModel(model);
connect(model, &QAbstractItemModel::rowsInserted, this, [this]() { dirty(); });
connect(model, &QAbstractItemModel::rowsRemoved, this, [this]() { dirty(); });
connect(model, &QAbstractItemModel::modelReset, this, [this]() {
loadedRowCount = 0;
dirty();
});
connect(model, &QAbstractItemModel::modelReset, this, [this]() { dirty(); });
}
QVariant CardDatabaseDisplayModel::data(const QModelIndex &index, int role) const
{
if (role == CardInfoRole) {
return QVariant::fromValue(cardInfoAt(index));
}
return QSortFilterProxyModel::data(index, role);
}
CardInfoPtr CardDatabaseDisplayModel::cardInfoAt(const QModelIndex &index) const
{
if (!index.isValid() || cardModel == nullptr) {
return {};
}
const QModelIndex sourceIndex = mapToSource(index);
return sourceIndex.isValid() ? cardModel->getCard(sourceIndex.row()) : CardInfoPtr();
}
QMap<wchar_t, wchar_t> CardDatabaseDisplayModel::characterTranslation = {{L'“', L'\"'},
@ -56,32 +71,6 @@ QMap<wchar_t, wchar_t> CardDatabaseDisplayModel::characterTranslation = {{L'“'
{L'‘', L'\''},
{L'’', L'\''}};
bool CardDatabaseDisplayModel::canFetchMore(const QModelIndex &index) const
{
return loadedRowCount < sourceModel()->rowCount(index);
}
void CardDatabaseDisplayModel::fetchMore(const QModelIndex &index)
{
int remainder = sourceModel()->rowCount(index) - loadedRowCount;
int itemsToFetch = qMin(100, remainder);
if (itemsToFetch == 0) {
return;
}
const auto startIndex = qMin(rowCount(QModelIndex()), loadedRowCount);
beginInsertRows(QModelIndex(), startIndex, startIndex + itemsToFetch - 1);
loadedRowCount += itemsToFetch;
endInsertRows();
}
int CardDatabaseDisplayModel::rowCount(const QModelIndex &parent) const
{
return QSortFilterProxyModel::rowCount(parent);
}
bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
{
const QString leftString = sourceModel()->data(left, CardDatabaseModel::SortRole).toString();

View file

@ -21,6 +21,7 @@
#include <libcockatrice/filters/filter_string.h>
#include <qtmetamacros.h>
class CardDatabaseModel;
class FilterTree;
class QObject;
template <class Key, class T> class QMap;
@ -36,13 +37,25 @@ public:
ShowAll
};
enum Role
{
/**
* @brief The CardInfoPtr behind a proxy row.
*
* Saves views from looking the card up again through its name, and keeps
* them from depending on the source model's column layout.
*/
CardInfoRole = Qt::UserRole + 1
};
private:
FilterBool isToken;
QString cardName, cardText;
QSet<QString> cardNameSet, cardTypes, cardColors;
FilterTree *filterTree;
FilterString *filterString;
int loadedRowCount;
/// The source model, kept typed so CardInfoRole does not cast per row.
CardDatabaseModel *cardModel;
QTimer dirtyTimer;
CardSearchLanguage searchLanguage;
QString searchText;
@ -85,9 +98,16 @@ public:
dirtyTimer.start(20);
}
void clearFilterAll();
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] bool canFetchMore(const QModelIndex &parent) const override;
void fetchMore(const QModelIndex &parent) override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/**
* @brief Returns the card behind a proxy row, without the QVariant wrapper of CardInfoRole.
*
* The visual grid calls this for every row on each rebuild; CardInfoRole stays the
* generic way in for consumers that only see the model through data().
*/
[[nodiscard]] CardInfoPtr cardInfoAt(const QModelIndex &index) const;
signals:
void modelDirty();

View file

@ -27,14 +27,18 @@ CardDatabaseModel::CardDatabaseModel(CardDatabase *_db, bool _showOnlyCardsFromE
CardDatabaseModel::~CardDatabaseModel() = default;
int CardDatabaseModel::rowCount(const QModelIndex & /*parent*/) const
int CardDatabaseModel::rowCount(const QModelIndex &parent) const
{
return cardList.size();
// A list model's card rows are leaves; returning the full card count for a
// valid parent makes every row claim all cards as children.
return parent.isValid() ? 0 : cardList.size();
}
int CardDatabaseModel::columnCount(const QModelIndex & /*parent*/) const
int CardDatabaseModel::columnCount(const QModelIndex &parent) const
{
return CARDDBMODEL_COLUMNS;
// Card rows are leaves, so a valid parent holds no columns. Reporting the
// row width for a card index would claim child cells that do not exist.
return parent.isValid() ? 0 : CARDDBMODEL_COLUMNS;
}
QVariant CardDatabaseModel::data(const QModelIndex &index, int role) const

View file

@ -0,0 +1,123 @@
#include "card_database_visual_display_model.h"
#include "card_database_display_model.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include <QAbstractItemModel>
#include <QList>
#include <QStringList>
#include <QVariant>
/// Coalesces the bursts of proxy signals a filter, sort or database load produces.
static constexpr int REBUILD_COALESCE_MS = 30;
CardDatabaseVisualDisplayModel::CardDatabaseVisualDisplayModel(CardDatabaseDisplayModel *_displayModel,
CardDatabaseQuerier *_querier,
QObject *parent)
: QAbstractListModel(parent), displayModel(_displayModel), querier(_querier)
{
rebuildTimer.setSingleShot(true);
rebuildTimer.setInterval(REBUILD_COALESCE_MS);
connect(&rebuildTimer, &QTimer::timeout, this, &CardDatabaseVisualDisplayModel::rebuild);
if (displayModel == nullptr) {
return;
}
connect(displayModel, &QAbstractItemModel::modelReset, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
connect(displayModel, &QAbstractItemModel::layoutChanged, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
connect(displayModel, &QAbstractItemModel::rowsInserted, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
connect(displayModel, &QAbstractItemModel::rowsRemoved, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
connect(displayModel, &QAbstractItemModel::dataChanged, this, &CardDatabaseVisualDisplayModel::scheduleRebuild);
}
void CardDatabaseVisualDisplayModel::setSetFilterTerms(const QStringList &terms)
{
if (setFilterTerms == terms) {
return;
}
setFilterTerms = terms;
refresh();
}
void CardDatabaseVisualDisplayModel::refresh()
{
rebuildTimer.stop();
rebuild();
}
void CardDatabaseVisualDisplayModel::scheduleRebuild()
{
rebuildTimer.start();
}
void CardDatabaseVisualDisplayModel::rebuild()
{
beginResetModel();
entries.clear();
if (displayModel != nullptr) {
const int proxyRows = displayModel->rowCount();
entries.reserve(proxyRows);
for (int row = 0; row < proxyRows; ++row) {
const CardInfoPtr info = displayModel->cardInfoAt(displayModel->index(row, 0));
if (info) {
appendPrintingsFor(info);
}
}
}
endResetModel();
}
void CardDatabaseVisualDisplayModel::appendPrintingsFor(const CardInfoPtr &info)
{
if (setFilterTerms.isEmpty()) {
entries.append({info, PrintingInfo()});
return;
}
const auto &printingsBySet = info->getSets();
for (const QString &term : setFilterTerms) {
const auto printings = printingsBySet.constFind(term);
if (printings == printingsBySet.constEnd()) {
continue;
}
for (const PrintingInfo &printing : printings.value()) {
entries.append({info, printing});
}
}
}
int CardDatabaseVisualDisplayModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : static_cast<int>(entries.size());
}
QVariant CardDatabaseVisualDisplayModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= entries.size()) {
return {};
}
if (role == Qt::DisplayRole || role == Qt::AccessibleTextRole) {
return cardAt(index.row()).getName();
}
return {};
}
ExactCard CardDatabaseVisualDisplayModel::cardAt(int row) const
{
if (row < 0 || row >= entries.size()) {
return {};
}
const Entry &entry = entries.at(row);
if (entry.printing.isEmpty() && setFilterTerms.isEmpty()) {
return querier != nullptr ? querier->getPreferredCard(entry.info) : ExactCard(entry.info);
}
return ExactCard(entry.info, entry.printing);
}

View file

@ -0,0 +1,90 @@
/**
* @file card_database_visual_display_model.h
* @ingroup CardDatabaseModels
* @brief A list model of the exact cards a visual card grid paints.
*
* The filtered and sorted CardDatabaseDisplayModel holds one row per card name.
* A visual grid shows one picture per *printing* instead, so this model flattens
* the proxy rows into ExactCards: the preferred printing per card normally, and
* every printing of the selected sets while set filters are active.
*/
#ifndef COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H
#define COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/card/printing/printing_info.h"
#include <QAbstractListModel>
#include <QList>
#include <QModelIndex>
#include <QPointer>
#include <QStringList>
#include <QTimer>
#include <qtmetamacros.h>
class CardDatabaseDisplayModel;
class CardDatabaseQuerier;
class CardDatabaseVisualDisplayModel : public QAbstractListModel
{
Q_OBJECT
public:
/**
* @brief Flattens _displayModel into the cards a visual grid displays.
* @param _displayModel The filtered and sorted proxy model of the card database.
* @param _querier Resolves the preferred printing of a card.
* @param parent Parent object.
*/
explicit CardDatabaseVisualDisplayModel(CardDatabaseDisplayModel *_displayModel,
CardDatabaseQuerier *_querier,
QObject *parent = nullptr);
/**
* @brief Sets the set shorthand names of the active set filters.
*
* With no terms every card is shown with its preferred printing. With terms
* every printing belonging to one of the named sets is shown instead, in term
* order. Rebuilds immediately when the terms change.
*/
void setSetFilterTerms(const QStringList &terms);
/**
* @brief Rebuilds the row list from the proxy model right away.
*
* Proxy model changes normally rebuild on a short coalescing timer; call this
* when the caller needs the new rows before returning.
*/
void refresh();
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/**
* @brief Returns the card painted at @p row, with its preferred printing resolved.
* @param row Row in this model, not in the proxy model.
*/
[[nodiscard]] ExactCard cardAt(int row) const;
private:
struct Entry
{
CardInfoPtr info;
/// Empty when the entry follows the preferred printing rather than a set filter.
PrintingInfo printing;
};
void scheduleRebuild();
void rebuild();
void appendPrintingsFor(const CardInfoPtr &info);
QPointer<CardDatabaseDisplayModel> displayModel;
CardDatabaseQuerier *querier;
QStringList setFilterTerms;
QList<Entry> entries;
QTimer rebuildTimer;
};
#endif // COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h)
add_library(

View file

@ -134,6 +134,13 @@ void DeckListModel::rebuildTree()
int DeckListModel::rowCount(const QModelIndex &parent) const
{
// Only column 0 carries children. Column 1 and later indexes are siblings
// used by views for the remaining cells of a row, and a model that hangs
// children off them breaks QAbstractItemModelTester and proxy models.
if (parent.isValid() && parent.column() != 0) {
return 0;
}
// debugIndexInfo("rowCount", parent);
auto *node = getNode<InnerDecklistNode *>(parent);
if (node) {
@ -282,7 +289,7 @@ QVariant DeckListModel::headerData(const int section, const Qt::Orientation orie
QModelIndex DeckListModel::index(int row, int column, const QModelIndex &parent) const
{
// debugIndexInfo("index", parent);
if (!hasIndex(row, column, parent)) {
if ((parent.isValid() && parent.column() != 0) || !hasIndex(row, column, parent)) {
return {};
}

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
add_subdirectory(libcockatrice/network/client)
add_subdirectory(libcockatrice/network/server)

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
add_subdirectory(abstract)
add_subdirectory(local)
add_subdirectory(remote)

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS abstract_client.h latency_tracker.h)
set(SOURCES abstract_client.cpp latency_tracker.cpp)
@ -15,7 +11,7 @@ target_include_directories(libcockatrice_network_client_abstract PUBLIC .)
target_link_libraries(
libcockatrice_network_client_abstract
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol
libcockatrice_network_server_remote
libcockatrice_network_server_remote libcockatrice_utility
)
target_precompile_headers(libcockatrice_network_client_abstract PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")

View file

@ -10,6 +10,7 @@
#include "libcockatrice/protocol/pb/serverinfo_user.pb.h"
#include "libcockatrice/protocol/pb/session_commands.pb.h"
#include "libcockatrice/protocol/pb/session_event.pb.h"
#include "libcockatrice/utility/passwordhasher.h"
#include <QDebug>
#include <QList>
@ -80,6 +81,26 @@ AbstractClient::~AbstractClient()
{
}
Command_AccountPassword AbstractClient::makeAccountPasswordCommand(const QString &oldPassword,
const QString &newPassword) const
{
Command_AccountPassword cmd;
cmd.set_old_password(oldPassword.toStdString());
if (getServerSupportsChallengeResponse()) {
// A verifier lets the server upgrade the stored credential without ever seeing the
// password, and it is what a challenge-response server expects for a new password.
cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString());
} else if (getServerSupportsPasswordHash()) {
// The server hashes the password itself with its own salt; a salted hash is the
// strongest form it accepts, so the plaintext never goes on the wire.
const QString passwordSalt = PasswordHasher::generateRandomSalt();
cmd.set_hashed_new_password(PasswordHasher::computeHash(newPassword, passwordSalt).toStdString());
} else {
cmd.set_new_password(newPassword.toStdString());
}
return cmd;
}
void AbstractClient::processProtocolItem(const ServerMessage &item)
{
switch (item.message_type()) {

View file

@ -47,6 +47,7 @@ class Event_UserJoined;
class Event_UserLeft;
class Event_ServerMessage;
class Event_ListRooms;
class Command_AccountPassword;
class Event_GameJoined;
class Event_UserMessage;
class Event_NotifyUser;
@ -67,6 +68,9 @@ enum ClientStatus
StatusSubmitForgotPasswordReset,
StatusSubmitForgotPasswordChallenge,
StatusGettingPasswordSalt,
//! The server accepted the credentials but keeps the session open until the account
//! password is replaced; the connection must survive for `Command_AccountPassword`.
StatusPasswordChangeRequired,
};
class AbstractClient : public QObject
@ -171,6 +175,16 @@ public:
{
return serverSupportsChallengeResponse;
}
/**
* @brief Builds the account-password command in the form this server understands.
*
* Challenge-response servers take a scrypt verifier; servers that only advertise
* SupportsPasswordHash take a salted SHA-512 hash; anything older gets the plaintext,
* which is the only form it understands. Both password-change paths go through here so
* the two cannot drift apart and hand a plaintext password to a server that can hash it.
*/
Command_AccountPassword makeAccountPasswordCommand(const QString &oldPassword, const QString &newPassword) const;
const QString &getUserName() const
{
return userName;

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS local_client.h)
set(SOURCES local_client.cpp)

View file

@ -33,16 +33,18 @@ LocalClient::LocalClient(LocalServerInterface *_lsi,
sendCommand(prepareSessionCommand(joinCmd));
}
LocalClient::~LocalClient()
{
}
void LocalClient::sendCommandContainer(const CommandContainer &cont)
{
if (LocalClientLog().isDebugEnabled()) {
qCDebug(LocalClientLog).noquote() << userName << "OUT" << getSafeDebugString(cont);
}
if (lsi.isNull()) {
qCWarning(LocalClientLog).noquote()
<< userName << "dropping command, local server interface no longer exists:" << getSafeDebugString(cont);
return;
}
lsi->itemFromClient(cont);
}

View file

@ -10,6 +10,7 @@
#include "../abstract/abstract_client.h"
#include <QLoggingCategory>
#include <QPointer>
#include <QString>
#include <qtmetamacros.h>
@ -24,14 +25,15 @@ class LocalClient : public AbstractClient
{
Q_OBJECT
private:
LocalServerInterface *lsi;
// Guarded pointer: the LocalServer (and its interfaces) may be destroyed while this client
// still has pending commands queued on the event loop.
QPointer<LocalServerInterface> lsi;
public:
LocalClient(LocalServerInterface *_lsi,
const QString &_playerName,
const QString &_clientId,
QObject *parent = nullptr);
~LocalClient() override;
void sendCommandContainer(const CommandContainer &cont) override;
private slots:

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS remote_client.h)
set(SOURCES remote_client.cpp)

View file

@ -125,6 +125,7 @@ RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSe
&RemoteClient::doSubmitForgotPasswordResetToServer);
connect(this, &RemoteClient::sigSubmitForgotPasswordChallengeToServer, this,
&RemoteClient::doSubmitForgotPasswordChallengeToServer);
connect(this, &RemoteClient::sigChangePasswordToServer, this, &RemoteClient::doChangePasswordToServer);
}
RemoteClient::~RemoteClient()
@ -354,6 +355,29 @@ void RemoteClient::submitPasswordVerifierResponse(const Response &response)
}
}
void RemoteClient::changePasswordToServer(const QString &oldPassword, const QString &newPassword)
{
emit sigChangePasswordToServer(oldPassword, newPassword);
}
void RemoteClient::doChangePasswordToServer(const QString &oldPassword, const QString &newPassword)
{
const Command_AccountPassword cmd = makeAccountPasswordCommand(oldPassword, newPassword);
PendingCommand *pend = prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &RemoteClient::accountPasswordResponse);
sendCommand(pend);
}
void RemoteClient::accountPasswordResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
emit sigPasswordChangeSuccess();
} else {
emit sigPasswordChangeError(response.response_code());
}
}
void RemoteClient::processConnectionClosedEvent(const Event_ConnectionClosed & /*event*/)
{
doDisconnectFromServer();
@ -475,6 +499,23 @@ void RemoteClient::loginResponse(const Response &response)
emit notifyUserAboutUpdate();
}
} else if (response.response_code() == Response::RespPasswordChangeRequired) {
// The credentials were accepted: the server keeps this session open and serves
// nothing but ping and Command_AccountPassword until the password is replaced.
// Treating it as a failed login would disconnect before the dialog that collects
// the new password (queued to the UI thread behind this slot) can send anything:
// the command would sit in pendingCommands forever, because the ping timer that
// times it out stops with the connection. Keep the session and let the controller
// report the outcome through accountPasswordResponse().
QList<QString> missingFeatures;
if (resp.missing_features_size() > 0) {
for (int i = 0; i < resp.missing_features_size(); ++i) {
missingFeatures << QString::fromStdString(resp.missing_features(i));
}
}
setStatus(StatusPasswordChangeRequired);
emit loginError(response.response_code(), QString::fromStdString(resp.denied_reason_str()),
static_cast<quint32>(resp.denied_end_time()), missingFeatures);
} else if (response.response_code() != Response::RespNotConnected) {
QList<QString> missingFeatures;
if (resp.missing_features_size() > 0) {

View file

@ -71,6 +71,11 @@ signals:
//! should only store it for the connection it is currently negotiating, so
//! the hostname and user name are intentionally not part of the signal.
void sigPasswordVerifierReady(const QString &verifier);
//! \brief Outcome of the password change the server demanded at login.
void sigPasswordChangeSuccess();
void sigPasswordChangeError(int responseCode);
//! \brief Request a password change; queued to the client thread like the other requests.
void sigChangePasswordToServer(const QString &oldPassword, const QString &newPassword);
private slots:
void slotConnected();
void readData();
@ -99,6 +104,8 @@ private slots:
Command_Login generateCommandLogin();
void doSubmitPasswordVerifier();
void submitPasswordVerifierResponse(const Response &response);
void accountPasswordResponse(const Response &response);
void doChangePasswordToServer(const QString &oldPassword, const QString &newPassword);
void doDisconnectFromServer();
void doActivateToServer(const QString &_token);
void doRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName);
@ -201,6 +208,10 @@ public:
unsigned int port,
const QString &_userName,
const QString &_email);
//! \brief Change the password of the account that just logged in, following the
//! server's forced-password-change reply. Emits sigPasswordChangeSuccess or
//! sigPasswordChangeError when the server answers.
void changePasswordToServer(const QString &oldPassword, const QString &newPassword);
};
#endif

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
add_subdirectory(local)
add_subdirectory(remote)

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS local_server.h local_server_interface.h)
set(SOURCES local_server.cpp local_server_interface.cpp)

View file

@ -1,7 +1,3 @@
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
set(HEADERS
game/dump_zone_range.h
game/server_abstract_participant.h
@ -12,6 +8,7 @@ set(HEADERS
game/server_cardzone.h
game/server_counter.h
game/game_config.h
game/role_info.h
game/server_deck_validation_strategy.h
game/server_game.h
game/server_game_lifecycle_strategy.h

View file

@ -0,0 +1,13 @@
#ifndef COCKATRICE_ROLE_INFO_H
#define COCKATRICE_ROLE_INFO_H
/**
* @brief Represents information about a participant's roles.
*/
struct RoleInfo
{
bool isJudge = false; ///< Whether the participant joined as a judge
bool isSpectator = false; ///< Whether the participant joined as a spectator
};
#endif // COCKATRICE_ROLE_INFO_H

View file

@ -61,10 +61,10 @@
Server_AbstractParticipant::Server_AbstractParticipant(Server_Game *_game,
int _playerId,
const ServerInfo_User &_userInfo,
bool _judge,
const RoleInfo &_roleInfo,
Server_AbstractUserInterface *_userInterface)
: ServerInfo_User_Container(_userInfo), game(_game), userInterface(_userInterface), pingTime(0),
playerId(_playerId), judge(_judge)
playerId(_playerId), roleInfo(_roleInfo)
{
}
@ -98,8 +98,8 @@ void Server_AbstractParticipant::getProperties(ServerInfo_PlayerProperties &resu
if (withUserInfo) {
copyUserInfo(*(result.mutable_user_info()), true);
}
result.set_spectator(spectator);
result.set_judge(judge);
result.set_spectator(isSpectator());
result.set_judge(isJudge());
result.set_ping_seconds(pingTime);
getPlayerProperties(result);
}
@ -176,7 +176,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdUnconcede(const Command_Un
Response::ResponseCode
Server_AbstractParticipant::cmdJudge(const Command_Judge &cmd, ResponseContainer &rc, GameEventStorage &ges)
{
if (!judge) {
if (!isJudge()) {
return Response::RespFunctionNotAllowed;
}
@ -204,7 +204,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdReadyStart(const Command_R
Response::ResponseCode
Server_AbstractParticipant::cmdGameSay(const Command_GameSay &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges)
{
if (spectator) {
if (isSpectator()) {
/* Spectators can only talk if:
* (a) the game creator allows it
* (b) the spectator is a moderator/administrator
@ -365,7 +365,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdNextTurn(const Command_Nex
return Response::RespGameNotStarted;
}
if (!judge) {
if (!isJudge()) {
return Response::RespFunctionNotAllowed;
}
@ -381,7 +381,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdSetActivePhase(const Comma
return Response::RespGameNotStarted;
}
if (!judge) {
if (!isJudge()) {
return Response::RespFunctionNotAllowed;
}
@ -415,8 +415,8 @@ Response::ResponseCode Server_AbstractParticipant::cmdReverseTurn(const Command_
ResponseContainer & /*rc*/,
GameEventStorage &ges)
{
if (!judge) {
if (spectator) {
if (!isJudge()) {
if (isSpectator()) {
return Response::RespFunctionNotAllowed;
}
@ -577,7 +577,7 @@ void Server_AbstractParticipant::setUserInterface(Server_AbstractUserInterface *
void Server_AbstractParticipant::disconnectClient()
{
bool isRegistered = userInfo->user_level() & ServerInfo_User::IsRegistered;
if (!isRegistered || spectator) {
if (!isRegistered || isSpectator()) {
game->removeParticipant(this, Event_Leave::USER_DISCONNECTED);
} else {
setUserInterface(nullptr);

View file

@ -2,6 +2,7 @@
#define ABSTRACT_PARTICIPANT_H
#include "../serverinfo_user_container.h"
#include "role_info.h"
#include "server_arrowtarget.h"
#include <QMutex>
@ -61,8 +62,7 @@ protected:
Server_AbstractUserInterface *userInterface;
int pingTime;
int playerId;
bool spectator;
bool judge;
RoleInfo roleInfo;
virtual void getPlayerProperties(ServerInfo_PlayerProperties &result);
mutable QMutex playerMutex;
@ -70,7 +70,7 @@ public:
Server_AbstractParticipant(Server_Game *_game,
int _playerId,
const ServerInfo_User &_userInfo,
bool _judge,
const RoleInfo &_roleInfo,
Server_AbstractUserInterface *_handler);
~Server_AbstractParticipant() override;
virtual void prepareDestroy()
@ -91,11 +91,11 @@ public:
}
bool isSpectator() const
{
return spectator;
return roleInfo.isSpectator;
}
bool isJudge() const
{
return judge;
return roleInfo.isJudge;
}
Server_Game *getGame() const
{

View file

@ -81,10 +81,9 @@ Server_AbstractPlayer::Server_AbstractPlayer(Server_Game *_game,
const ServerInfo_User &_userInfo,
bool _judge,
Server_AbstractUserInterface *_userInterface)
: Server_AbstractParticipant(_game, _playerId, _userInfo, _judge, _userInterface), conceded(false), deck(nullptr),
sideboardLocked(true), readyStart(false), nextCardId(0)
: Server_AbstractParticipant(_game, _playerId, _userInfo, {.isJudge = _judge}, _userInterface), conceded(false),
deck(nullptr), sideboardLocked(true), readyStart(false), nextCardId(0)
{
spectator = false;
}
Server_AbstractPlayer::~Server_AbstractPlayer() = default;
@ -295,7 +294,7 @@ Response::ResponseCode Server_AbstractPlayer::moveCard(GameEventStorage &ges,
{
// Disallow controller change to other zones than the table.
if (((targetzone->getType() != ServerInfo_Zone::PublicZone) || !targetzone->hasCoords()) &&
(startzone->getPlayer() != targetzone->getPlayer()) && !judge) {
(startzone->getPlayer() != targetzone->getPlayer()) && !isJudge()) {
return Response::RespContextError;
}
@ -808,7 +807,7 @@ Server_AbstractPlayer::cmdMoveCard(const Command_MoveCard &cmd, ResponseContaine
return Response::RespNameNotFound;
}
if ((startPlayer != this) && (!startZone->getPlayersWithWritePermission().contains(playerId)) && !judge) {
if ((startPlayer != this) && (!startZone->getPlayersWithWritePermission().contains(playerId)) && !isJudge()) {
return Response::RespContextError;
}
@ -821,7 +820,7 @@ Server_AbstractPlayer::cmdMoveCard(const Command_MoveCard &cmd, ResponseContaine
return Response::RespNameNotFound;
}
if ((startPlayer != this) && (targetPlayer != this) && !judge) {
if ((startPlayer != this) && (targetPlayer != this) && !isJudge()) {
return Response::RespContextError;
}
@ -1598,7 +1597,7 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo
zone->addWritePermission(cmd.player_id());
}
if (judge) {
if (isJudge()) {
ges.setOverwriteOwnership(true);
}

View file

@ -41,6 +41,7 @@
#include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_user.pb.h"
#include "libcockatrice/protocol/pb/session_event.pb.h"
#include "role_info.h"
#include "server_abstract_player.h"
#include "server_abstractuserinterface.h"
#include "server_arrow.h"
@ -479,9 +480,8 @@ void Server_Game::stopGameIfFinished()
Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user,
const QString &_password,
bool spectator,
bool overrideRestrictions,
bool asJudge)
const RoleInfo &roleInfo,
bool overrideRestrictions)
{
Server_DatabaseInterface *databaseInterface = room->getServer()->getDatabaseInterface();
for (auto *participant : participants.values()) {
@ -490,11 +490,11 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user,
}
}
if (asJudge && !(user->user_level() & ServerInfo_User::IsJudge)) {
if (roleInfo.isJudge && !(user->user_level() & ServerInfo_User::IsJudge)) {
return Response::RespUserLevelTooLow;
}
if (!(overrideRestrictions && (user->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsJudge)))) {
if ((_password != password) && !(spectator && !spectatorsNeedPassword)) {
if ((_password != password) && !(roleInfo.isSpectator && !spectatorsNeedPassword)) {
return Response::RespWrongPassword;
}
if (!(user->user_level() & ServerInfo_User::IsRegistered) && onlyRegistered) {
@ -510,13 +510,13 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user,
QString::fromStdString(user->name()))) {
return Response::RespInIgnoreList;
}
if (spectator) {
if (roleInfo.isSpectator) {
if (!spectatorsAllowed) {
return Response::RespSpectatorsNotAllowed;
}
}
}
if (!spectator && (gameStarted || (getPlayerCount() >= getMaxPlayers()))) {
if (!roleInfo.isSpectator && (gameStarted || (getPlayerCount() >= getMaxPlayers()))) {
return Response::RespGameFull;
}
@ -537,19 +537,18 @@ bool Server_Game::containsUser(const QString &userName) const
void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface,
ResponseContainer &rc,
bool spectator,
bool judge,
const RoleInfo &roleInfo,
bool broadcastUpdate)
{
QMutexLocker locker(&gameMutex);
Server_AbstractParticipant *newParticipant;
if (spectator) {
if (roleInfo.isSpectator) {
newParticipant = new Server_Spectator(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true),
judge, userInterface);
roleInfo.isJudge, userInterface);
} else {
newParticipant = new Server_Player(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true), judge,
userInterface);
newParticipant = new Server_Player(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true),
roleInfo.isJudge, userInterface);
}
newParticipant->moveToThread(thread());
@ -560,7 +559,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface,
const QString playerName = QString::fromStdString(newParticipant->getUserInfo()->name());
participants.insert(newParticipant->getPlayerId(), newParticipant);
if (spectator) {
if (roleInfo.isSpectator) {
allSpectatorsEver.insert(playerName);
} else {
allPlayersEver.insert(playerName);
@ -579,7 +578,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface,
emit gameInfoChanged(gameInfo);
}
if ((newParticipant->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) && !spectator) {
if ((newParticipant->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) && !roleInfo.isSpectator) {
room->getServer()->addPersistentPlayer(playerName, room->getId(), gameId, newParticipant->getPlayerId());
}
@ -871,6 +870,13 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti
}
}
bool Server_Game::canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const
{
return participant->getPlayerId() == privatePlayerId // same player
|| participant->isJudge() // judges see everything
|| (participant->isSpectator() && spectatorsSeeEverything); // spectators see everything
}
void Server_Game::sendGameEventContainer(GameEventContainer *cont,
GameEventStorageItem::EventRecipients recipients,
int privatePlayerId)
@ -879,8 +885,7 @@ void Server_Game::sendGameEventContainer(GameEventContainer *cont,
cont->set_game_id(gameId);
for (auto *participant : participants.values()) {
const bool playerPrivate = (participant->getPlayerId() == privatePlayerId) || participant->isJudge() ||
(participant->isSpectator() && spectatorsSeeEverything);
const bool playerPrivate = canSeePrivateEventsFor(participant, privatePlayerId);
if ((recipients.testFlag(GameEventStorageItem::SendToPrivate) && playerPrivate) ||
(recipients.testFlag(GameEventStorageItem::SendToOthers) && !playerPrivate)) {
participant->sendGameEvent(*cont);

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