Compare commits

...

42 commits

Author SHA1 Message Date
tooomm
77563586c9 Merge branch 'master' into tooomm-cache_mgmt 2026-09-06 14:54:30 +02:00
tooomm
048fe247f4
Add ccache eviction to debug builds as well (#7247)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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-09-06 14:05:47 +02:00
RickyRister
0f003eabf9
[Game] Implement total toughness tally (#7252) 2026-09-06 01:46:37 -07:00
BruebachL
ada774f5cc
[Game] Render custom deck zones in the deck view (#7207)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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
* [Game] Render custom deck zones in the deck view

The in-game deck view now walks custom zones like the standard
boards, so cards filed under a user-created zone show up in their
zone's card pile instead of disappearing from the view.

* [Game] Collect deck-view cards via DeckList::getCardNodes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-05 22:00:22 +02:00
BruebachL
b0e566ed54
[Client] Show custom zones in the card display widgets (#7206)
* [Client] Show custom zones in the card display widgets

Card group displays and deck zone displays learn to render custom
zones alongside the standard boards.

- Group display widgets treat custom-zone nodes like other group
  headers, keeping counts and layout consistent.
- Zone display widgets resolve their title through visibleNameFromName
  so custom zones show their user-chosen names localized like the
  standard zones.

* [DeckEditor] Apply sort criteria inside custom zones and align display order with the model

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-05 22:00:21 +02:00
BruebachL
0d09e633e3
[Client] Expose custom zone management in the deck editor (#7205)
* [Client] Expose custom zone management in the deck editor

Wires the state layer into every editor surface that shows deck zones.

- Deck dock: context menu on zones gains New/Rename/Delete/Change
  board actions, with per-zone submenus for adding cards.
- Card database dock and visual database display gain an add-to-zone
  submenu listing custom zones per board plus a create-zone entry.
- All prompt call sites pass validateNewZoneName so duplicates and
  reserved names are rejected inline before Ok unlocks.
- Rename reuses the same dialog in name-only mode, keeping one
  validation contract for every zone-name entry point.
- Change board marks the current board instead of offering a no-op,
  and the state layer refuses moves onto boards holding a same-named
  zone from imported decks.

* [DeckEditor] Address custom-zone menu and export review feedback

* [DeckLoader] Keep the sideboard marker and block ordering when exporting nested zones

- saveToStream_DeckZone threads the owning board zone name down to the card
  writer, so cards in a custom zone under the sideboard keep their SB:
  prefix instead of being re-imported into the maindeck
- nested sub-zones are collected during the loop and written after the
  parent zone's own header and cards, so they no longer read as part of the
  zone printed before them

* [DeckEditor] Fix move-to-zone menu use-after-free and per-zone enabled state

- resolve the card name/provider/collector number before createNewCustomZone
  rebuilds the model tree, then re-find the refreshed index via findCard and
  move it (mirrors the decrementCard re-find pattern)
- the enabled test now compares the card's own zone (nearest custom-zone
  ancestor, else its board), matching moveCardToZone's lookup, so moving a
  card out of a custom zone back to the board root is offered and the card's
  own zone is disabled

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-05 22:00:21 +02:00
BruebachL
9677fad342
[Client] Add zone management to the deck state manager (#7204)
* [Client] Add zone management to the deck state manager

State-layer operations for custom deck zones, plus the shared prompt
dialog that later editor menus will call into.

- moveCardToZone relocates every copy of a card row into any zone,
  refusing non-card rows and tokens so miswired selections can never
  shred a group or turn tokens into deck cards. The current zone is
  found by walking ancestors, which also handles legacy top-level
  zones.
- createCustomZone, renameCustomZone, moveCustomZone and
  removeCustomZone wrap the tree API with memento history, model
  rebuilds and deck hash refreshes via modifyTree.
- Same-board zone moves return success without minting a history
  entry, keeping the undo log honest.
- promptForNewZone asks for a name and the parent zone, keeps Ok
  disabled until the trimmed name passes a caller-supplied validator
  (shown inline as an error), and reports its own translation context.

Took 14 minutes

# Commit time for manual adjustment:
# Took 6 minutes

# Commit time for manual adjustment:
# Took 33 seconds

* [DeckEditor] Address zone-management review feedback

- Expose DecklistNodeTree::hasZoneName and use it in validateNewZoneName
  so the uniqueness scan covers custom zones on every board, not just the
  standard ones.
- Hide the board selector in the rename dialog path where it is not used.
- Emit deckHashChanged after refreshDeckHash so the deck hash label stays
  current after zone create/rename/move/remove.

* [DeckEditor] Notify card set changes after zone edits and drop the board scan

- modifyTree emits cardNodesChanged alongside deckHashChanged so the
  banner-card combo and printing in-deck counts refresh after removing a
  zone that still holds cards
- DecklistNodeTree::findCustomZoneByName is public and moveCustomZone uses
  it, locating zones under non-standard boards (e.g. tokens) instead of
  scanning only main/side/maybeboard

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-05 22:00:21 +02:00
BruebachL
e8ec28572f
[Models] Mirror custom deck zones in the deck list model (#7203)
* [Models] Mirror custom deck zones in the deck list model

DeckListModel now surfaces the custom zones from the deck tree so
views can render and edit them alongside criteria groups.

The custom-zone bookkeeping that made the model unwieldy is extracted
into DeckListModelCustomZones (deck_list_model_custom_zones.h/.cpp), a
single self-contained unit owning every "what is / where is a custom
zone" decision for the model's shadow tree:

- rebuildTree mirrors each custom zone as a DecklistModelSubZoneNode
  under its board zone, cards flat inside (no further grouping).
- The freshly built shadow tree is sorted while the model reset is
  still open, so views never observe unsorted intermediate order and
  proxies cannot desync.
- Custom zones always sort after criteria groups within a board,
  regardless of their names. One shared sortWithCustomZonesLast backs
  both the live sortHelper (which remaps persistent indexes from the
  movement mapping) and the silent reset-time sortShadowTree.
- addCard inserts flat into a custom zone by name and keeps grouping
  by active criteria for board zones. findCardNode resolves cards in
  both layouts, legacy top-level zones unchanged.
- New IsCustomZoneRole lets views tell zones apart from groups.
- Empty custom zones survive row removal. Zone rows themselves are
  only mutable through the deck tree API.

A new deck_list_model_custom_zones_test suite locks the extracted
shadow-tree logic (type testing, mirroring, name lookup, and the
sort-with-custom-zones-last mapping).

No behavior change.

* [Models] Route group lookups around mirrored custom zones

Group lookups (createNodeIfNeeded, findCardNode) must not resolve a
mirrored custom zone that shares the group name. Introduce
findGroupChild to search only non-custom children, and make addCard
consult the deck tree before falling back to creating a top-level zone
so cards added to an un-mirrored custom zone land inside it.

mirrorCustomZones now flattens cards nested at any depth into the
mirrored zone so no card is left without a model row.

Add model behaviour tests (addCard routing, same-name group/zone
collision, removeRows guard, empty-zone survival, findCard inside a
custom zone) and fix the missing main() in the unit test binaries.

* [Models] Fix addCard routing for card-named zones and nested custom zones

- hasDeckZone no longer matches board cards that merely share the zone
  name, which previously caused infinite addCard/rebuildTree recursion
- Adding to a custom zone whose deck side holds nested sub-zones appends
  to the deck tree instead of writing past its direct children

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-05 22:00:20 +02:00
Magnus Groß
0c725f9a03
Allow to filter sets by release date (#7239)
This compares the release dates of sets, which enables users to filter
for sets in a certain range, for example to filter for all commanders
with an old card frame, `t:legendary set<8ED` can be used, which will
only include cards appearing before 8th edition.

This acts as a more powerful superset of the "Filter to X most recent
sets" feature.

Fixes #7238
2026-09-05 11:58:37 -07:00
BruebachL
c011ea7ceb
[Oracle] Parse sets lazily to slash importer peak memory (#7217)
* [Oracle] Parse sets lazily to slash importer peak memory

- Add a raw JSON scanner that splits the document into per-set byte ranges
  without materializing the JSON tree
- Keep only the raw document bytes and parse one set at a time in startImport
- Take readSetsFromByteArray by value so the wizard's buffer is moved, not copied
- Clear the retained raw data in releaseSetData()/clear()
- Cover the scanner and lazy parsing with tests

Took 2 minutes

* [Oracle] Fix nesting-depth cap, tolerate unescaped control chars, lazy-parse review fixes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-05 20:35:28 +02:00
BruebachL
1dc54617ba
[Oracle] Add RAM usage benchmarks for the oracle importer (#7216)
* [Oracle] Add RAM usage benchmarks for the oracle importer

- Measure process peak/current RSS via procfs (Linux) or getrusage (macOS)
- Add a synthetic-scale RAM benchmark and an opt-in real AllPrintings
  run gated by COCKATRICE_ORACLE_RAM_BENCHMARK=1
- Mirror the wizard's magic-byte handling to decompress .xz/.zip payloads
- Wire optional ZLIB/LibLZMA into the benchmark target and raise its timeout

Took 2 minutes

* [Oracle/Tests] Measure parse against post-fixture baseline; assert release empties sets

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-05 20:35:27 +02:00
BruebachL
aa96d81e4b
[Build] Enable ccache by default when it is installed (#7236)
* [Build] Enable ccache by default when it is installed

ccache is a near free win for both clean and incremental rebuilds and
has no effect on systems where it is not installed (find_program
guards the whole block). Aligns the CMake default with the documented
behavior; users can still arch with -DUSE_CCACHE=OFF.

* [Build] Disable ccache auto-engage on Windows (MSVC)

* [Build] Report ccache skip on Windows explicitly

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-05 19:40:37 +02:00
BruebachL
14ecfff700
[Build] Add precompiled headers for Qt-backed executables (#7235)
* [Build] Add precompiled headers for Qt-backed executables

Reparsing QtCore/QtGui/QtWidgets/QtNetwork in ~460 client translation
units is the dominant compilation cost. Precompile the two common layers:
- qtcore_pch.h (Qt Core only; safe even for headless Servatrice)
- qtwidgets_pch.h (adds Gui/Widgets/Network; used by Cockatrice and Oracle)

target_precompile_headers() requires CMake 3.16, now the project minimum.
Estimated 30-50% faster client rebuilds.

* [Build] Format qtwidgets precompiled header

clang-format include regrouping and a missing trailing newline.

* [Build] Add PCH-aware ccache sloppiness config; format cmake/pch headers

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-05 19:40:36 +02:00
BruebachL
61e6a9913e
[Oracle] Add oracle importer tests and fix set parsing details (#7215)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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
* [Oracle] Add oracle importer tests and fix set parsing details

- Add oracle_importer_test and oracle_importer_benchmark_test targets
- Preserve the first printing's legalities when an existing card is reused
- Concatenate split-card coloridentity and sort/dedupe card colors
- Use a raw string for the Basic Land format regex
- Pre-allocate the card hash and micro-optimize string handling

Took 2 minutes

* [Oracle/Tests] Pin cmc coercion in CI run; scope the reserve pass

The #7214 coercion assertion lived only in oracle_importer_benchmark_test,
which gets no add_test and so never runs under ctest. Add NumericManaValueCoercedToCmc
and LegacyConvertedManaCostCoercedToCmc to oracle_importer_test (a CI-ran
binary): manaValue/convertedManaCost are JSON numbers in AllPrintings, and
QJsonValue::toString() would drop them to an empty cmc without the
#7214 coercion fix.

Wrap the distinct-name reserve pass in a bare block so the ~35k name
QStrings are handed back before the memory-heavy import loop starts.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-04 22:14:16 +02:00
BruebachL
4d4ddd4278
[Oracle] Replace vendored qt-json with native QJson for set import (#7214)
* [Oracle] Replace vendored QtJson with native QJson for set import

- Drop the vendored oracle/src/qt-json/json.{h,cpp} implementation
- Switch SetToDownload and importCardsFromSet from QList<QVariant> to
  native QJsonArray/QJsonObject
- Release set JSON data after import in the save sets page

Took 20 minutes

* [Oracle] Restore property coercion and legality merge in native JSON import

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-04 22:14:16 +02:00
BruebachL
d6fbfb32a1
[Security] Use a CSPRNG for salts, tokens, and RNG seeding (#7192)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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
* [Security] Use a CSPRNG for salts, tokens, and RNG seeding

Password salts and activation tokens were generated with the global SFMT
RNG, which was seeded from a 32-bit timestamp, making registration
salts and activation tokens predictable. The game RNG used the same
timestamp seed across restarts.

Add CryptoUtil backed by OpenSSL RAND_bytes and use it for salt/token
generation and to seed RNG_SFMT with a 64-bit CSPRNG value in both the
client and server. Link libcockatrice_utility against OpenSSL::Crypto.

Took 30 seconds

Took 25 minutes

* Lint.

Took 4 minutes

Took 36 seconds

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-04 13:49:18 +02:00
BruebachL
3ec62df3e7
[Protocol] Remove duplicate event_game_state_changed.proto entry (#7234)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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
The .proto file appeared twice in the PROTO_FILES list, causing protoc to
process it twice on every build. Keep a single entry.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-04 05:20:53 +02:00
BruebachL
fcfb14cf56
[Build] Use pipes for GCC/Clang compilation (#7233)
Pass -pipe so GCC/Clang transfer intermediate representation between
compiler stages over pipes instead of temporary files, reducing build I/O.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-04 05:20:53 +02:00
BruebachL
35ebae8d7f
[Build] Bump cmake_minimum_required from 3.10 to 3.16 (#7232)
3.16 is already required by Qt6 (and enforced at find_package time).
This unlocks native target_precompile_headers(), better AUTOMOC/AUTORCC
handling, and qt6_finalize_project() without a version guard.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-04 05:20:52 +02:00
BruebachL
4e9d148163
[TabSupervisor] Initialize all tabs (#7231)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-01 16:11:02 +02:00
RickyRister
425b16ea0d
[Game] Allow dropping cards at bottom of stack zone (#7230)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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-09-01 12:11:31 +02:00
BruebachL
45c7ff6f87
[Mods] Properly close card art rules tab on disconnect (#7227)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-01 11:31:49 +02:00
BruebachL
d974501277
[GameScene] Sever connections properly. (#7191)
* [GameScene] Sever connections properly.

Took 2 minutes


Took 54 minutes

* [GameScene] Sever animated item destroy connections at teardown

Fix crash when a replay's board is closed (GameScene teardown abort).

The old QObject::disconnect(nullptr, nullptr, this, nullptr) is invalid per
Qt docs (the sender must never be nullptr), so it never severed the PMF
destroyed -> GameScene::removeAnimatedItem connections that fire when
QGraphicsScene::~QGraphicsScene -> clear() destroys the remaining items.

Store the QMetaObject::Connection handle for each animated item and
disconnect them all in ~GameScene via the connection-handle overload.
Dedup connections on the connection map rather than animatedItems, since
the animation timer clears animatedItems on completion, which let a
re-registered item (e.g. a life counter flashed repeatedly) accumulate
orphaned duplicate destroyed connections that survived teardown.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-01 11:31:11 +02:00
RickyRister
9bf2202739
[Game] Fix dragged card always placed on bottom of stack (#7228)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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-08-31 07:31:02 -07:00
RickyRister
03de1af678
[VDS] Fix search filter not being applied on refresh (#7229)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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-08-31 10:28:50 +02:00
RickyRister
3dc9dba67a
[SettingsPage] Refactor: Clean up order of variables (#7184)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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
* [SettingsPage] Refactor: Clean up order of variables

* fixes
2026-08-30 14:43:23 -07:00
BruebachL
68e4fa054d
[UserList] Add invite button to hover popup (#7144)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
* [Client] Send game invites from the user context menu via a private message

The user context menu gains an "Invite to Game" submenu listing the
inviteable games in the room (the inviter's own games, honoring the
buddy-only setting). Picking one opens a private message to the target
user with a cockatrice://joingame link naming the game, so the target
gets a clickable invite instead of a raw URL. Multi-game rooms offer a
picker; a single inviteable game sends directly. Sending a message to
an offline user no longer swallows the draft — it reports that the
user is offline and keeps the typed text.

Took 30 seconds

Took 1 minute

* [Client] Open the invite dialog taller by default without enforcing a minimum size

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-29 21:35:28 +02:00
BruebachL
6f86c45ea8
[Refactor] Extract shared deck conversion prompt helper (#7107)
Move the deck-to-.cod conversion prompt logic (format check, saved
preference handling, overwrite confirmation, dialog) out of
DeckPreviewWidget into dlg_convert_deck_to_cod_format so the deck
editor can reuse it without duplicating it.

Took 4 minutes

Took 4 minutes

Took 1 minute

# Commit time for manual adjustment:
# Took 3 minutes

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-29 21:12:11 +02:00
dependabot[bot]
dade7ae78a
Bump actions/checkout from 6 to 7 (#7210)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 18:23:45 +02:00
dependabot[bot]
8f52223322
Bump actions/download-artifact from 7 to 8 (#7209)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-29 18:11:11 +02:00
tooomm
704611fc1f
Correct ccache default setting (#7190)
* gate ccache correctly

* Revert "gate ccache correctly"

This reverts commit 0af93629de.

* Do not enable ccache by default
2026-08-29 15:21:38 +02:00
ebbit1q
4b7b785452
add a limit to the size of deckfiles cockatrice can load (#7163)
* add a limit to the size of deckfiles cockatrice can load

the limit is 99999 or 100k -1 right now, which is kind of the limit of
what looks acceptable in the player

* format

* up limit to 100k because that's what the tests do
2026-08-29 15:21:04 +02:00
ebbit1q
cf8e5858ab
increase the timeout for the hashing performance test (#7166)
it seems like there is too much variance across platforms for how long this test takes
probably fixes #7152
2026-08-29 15:20:49 +02:00
RickyRister
83833f4684
[UserList] Refactor: split dialog code to separate file (#7189)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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-08-28 22:50:06 -07:00
BruebachL
0a09884c78
[DeckList] Add custom deck zones to the deck tree (#7176)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
* [DeckList] Add custom deck zones to the deck tree

Introduce user-definable zones nested under a board zone (main, side
or maybeboard) so players can organize cards inside a board without
changing board semantics.

- addCustomZone, renameCustomZone, moveCustomZone and removeCustomZone
  manage zones. Names are unique across the whole deck and the standard
  zone names (main/side/maybeboard/tokens) stay reserved.
- Board zones are created lazily on first use.
- getZoneObjFromName resolves custom names to their nested node so
  addCard and XML loading route cards into them. Unknown names keep
  creating legacy top-level zones.
- deleteNode keeps empty custom zones alive and only prunes empty
  board zones.
- New deck_list_zones test suite locks hash parity with flat decks,
  sideboard size accounting, maybeboard exclusion from plain export
  and native-format round-trips.

Took 17 minutes


Took 11 minutes

* Extract to function

Took 4 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-26 23:15:16 +02:00
BruebachL
e12293bb28
[GameScene] Don't just sever self connections, sever them all. (#7188)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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
Took 21 minutes

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-26 02:22:57 +02:00
RickyRister
dba7cc73a4
[HomeTab] Introduce button color source setting (#7181)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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-08-25 19:27:07 +02:00
BruebachL
b3e126f904
[VDS] Drive folder and preview widgets from the model (#7106)
* [VDS] Drive folder and preview widgets from the model (MVC views)

Took 16 minutes

Took 8 minutes

Took 3 minutes


Took 11 minutes

* Rebase whoopsie

Took 4 minutes

* Hide widgets instead of destroying, go back to signals, rename for consistency.

Took 13 minutes

Took 4 seconds

Took 26 minutes

Took 5 seconds

# Commit time for manual adjustment:
# Took 9 minutes

* Make VDS startup smooth: batch deck loads, guard preview resizes

- Move color identity computation into the background load task and apply
  finished deck loads in bounded batches per event loop turn, so finishing
  hundreds of loads at once cannot stall the UI thread
- Skip redundant resize work in DeckPreviewWidget when the banner width did
  not change, and collect the clamped children once instead of searching the
  widget tree on every layout pass

Took 19 minutes

# Commit time for manual adjustment:
# Took 3 minutes

* [VDS] Expose filter matches as a proxy role instead of dropping rows

The folder display scanned source-model rows and probed acceptance with
mapFromSource(...).isValid(), reaching into both models for one answer.

The proxy now keeps every row and exposes each row's search/tag/color
filter result through FilterMatchRole. The folder display and the tag
filter read everything off proxy indexes, and hidden previews keep their
sorted position in the flow layout instead of being appended at the end.

Took 11 minutes

* [VDS] Bound pending-load drain by time and make row lookups O(1)

The fixed DECK_LOADS_PER_TURN = 24 cap had no measured basis. It was guessed and existed
because every applied load emitted dataChanged into each DeckPreviewWidget,
whose handler resolved its own row with an O(n) linear scan per widget.

The model now maintains a file path -> row hash kept in sync across scans,
renames and deletions, so rowForFilePath is O(1) and the fan-out cost is
gone at its source. The drain applies finished loads until a small time
budget per event loop turn runs out, so throughput self-tunes instead of
relying on an arbitrary count.

* Actual minimal fix for resize squishing

Took 20 minutes

* Fix color widget sizing

Took 16 minutes

* [BannerWidget] Also set a max height

Took 4 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-25 19:23:16 +02:00
BruebachL
e589429bd9
[Client] Pin user list header length to the viewport width (#7158)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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] Pin user list header length to the viewport width

The header stretch mode kept a resize section property, so after any
column grew past the viewport the list carried an invisible horizontal
pan range that scrolled rows sideways without visual feedback

Drop the leftover property so displayed length always equals viewport
width and horizontal panning is impossible

* Show columns 1 and 2

Took 12 minutes

Took 2 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-25 06:33:57 +02:00
BruebachL
2b7b4e8168
[App] Add onboarding wizard (#7064)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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
* [App] Add onboarding wizard

Took 10 minutes

Took 3 minutes

Took 7 minutes

Took 9 minutes

Took 2 minutes

Took 1 minute


Took 7 minutes

* Adjust CI

Took 14 minutes

Took 56 seconds

Took 2 seconds

Took 3 seconds

* Adjust CI again

Took 14 minutes

Took 2 seconds

* Comments and fixes

Took 9 seconds


Took 1 minute

* Rebase.

Took 5 minutes

Took 50 seconds

Took 15 seconds

* Comments.

Took 7 minutes

* CI lol

Took 3 minutes

* CI again lol

Took 4 minutes

* Drop some settings, add some new ones.

Took 19 minutes

* Resize when expanding section

Took 4 minutes

Took 3 minutes

Took 7 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-24 23:05:58 +02:00
BruebachL
815c5987b4
[Doxygen] Add troubleshooting for card pictures and logs (#7125)
* [Doxygen] Add troubleshooting for card pictures and logs

Took 3 minutes

* Apply suggestion from @tooomm

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

* Apply suggestion from @tooomm

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

* [Doxygen] Address review feedback on troubleshooting docs

- enabling_debug_logs.md: use shell code fence for terminal commands,
  add export alternative for macOS
- fixing_card_pictures.md: split log section into 'Check Logs' and
  'Enable Picture Loader Debug Logs', remove hardcoded URL list
  (defaults may drift), remove redundant Scryfall/Gatherer note
  (covered in Provider Accuracy section)

Took 1 minute

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: tooomm <tooomm@users.noreply.github.com>
2026-08-24 21:38:04 +02:00
BruebachL
24d8d8be3b
[VDS] Cache mana symbol renders and skip redundant resizes (#7167)
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 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (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 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
* [VDS] Cache mana symbol renders and skip redundant resizes

- Render each mana symbol once at a bounded master size and derive every
  requested size from the cached master, avoiding repeated full-size SVG
  rasterization on the GUI thread
- Share scaled results through a process-wide cache keyed by symbol and
  size, so repeated widget creation and rescales don't redo the work
- Skip redundant resize work in ColorIdentityWidget and ManaSymbolWidget
  when sizes did not change

Took 8 minutes


Took 50 seconds

* Move to pixmap generator

Took 8 minutes

Took 4 seconds

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-24 19:26:41 +02:00
191 changed files with 10775 additions and 3052 deletions

View file

@ -8,10 +8,13 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \
gtest \
mariadb-libs \
ninja \
openssl \
protobuf \
qt6-base \
qt6-declarative \
qt6-imageformats \
qt6-multimedia \
qt6-shadertools \
qt6-svg \
qt6-tools \
qt6-translations \

View file

@ -15,12 +15,15 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \
qt6-l10n-tools \
qt6-multimedia-dev \
qt6-declarative-dev \
qt6-svg-dev \
qt6-shadertools-dev \
qt6-tools-dev \
qt6-tools-dev-tools \
qt6-websockets-dev \

View file

@ -16,12 +16,15 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \
qt6-l10n-tools \
qt6-multimedia-dev \
qt6-declarative-dev \
qt6-svg-dev \
qt6-shadertools-dev \
qt6-tools-dev \
qt6-tools-dev-tools \
qt6-websockets-dev \

View file

@ -7,8 +7,9 @@ RUN dnf install -y \
git \
mariadb-devel \
ninja-build \
openssl-devel \
protobuf-devel \
qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \
qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \
qt6-qtimageformats \
rpm-build \
xz-devel \

View file

@ -7,8 +7,9 @@ RUN dnf install -y \
git \
mariadb-devel \
ninja-build \
openssl-devel \
protobuf-devel \
qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \
qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \
qt6-qtimageformats \
rpm-build \
xz-devel \

View file

@ -12,6 +12,7 @@ RUN apt-get update && \
libmariadb-dev-compat \
libprotobuf-dev \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-tools-dev \

View file

@ -15,12 +15,15 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \
qt6-l10n-tools \
qt6-multimedia-dev \
qt6-declarative-dev \
qt6-svg-dev \
qt6-shadertools-dev \
qt6-tools-dev \
qt6-tools-dev-tools \
qt6-websockets-dev \

View file

@ -16,12 +16,15 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \
qt6-l10n-tools \
qt6-multimedia-dev \
qt6-declarative-dev \
qt6-svg-dev \
qt6-shadertools-dev \
qt6-tools-dev \
qt6-tools-dev-tools \
qt6-websockets-dev \

View file

@ -149,6 +149,9 @@ if [[ $MAKE_TEST ]]; then
fi
if [[ $USE_CCACHE ]]; then
flags+=("-DUSE_CCACHE=1")
# PCH-aware caching is required or ccache refuses to cache any TU that
# consumes a precompiled header, silently recompiling everything on every run.
ccache --set-config sloppiness=pch_defines,time_macros
if [[ $CCACHE_SIZE ]]; then
# note, this setting persists after running the script
ccache --max-size "$CCACHE_SIZE"

View file

@ -40,7 +40,7 @@ jobs:
steps:
- name: "Checkout repository"
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: "Initialize CodeQL"
uses: github/codeql-action/init@v4
@ -68,7 +68,9 @@ jobs:
libprotobuf-dev \
ninja-build \
protobuf-compiler \
qt6-declarative-dev \
qt6-multimedia-dev \
qt6-shadertools-dev \
qt6-svg-dev \
qt6-tools-dev \
qt6-tools-dev-tools \

View file

@ -176,8 +176,12 @@ jobs:
shell: bash
run: |
source .ci/docker.sh
RUN --server --debug --test --ccache "$CCACHE_SIZE" \
--cmake-generator "$CMAKE_GENERATOR"
args=()
[[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE")
args+=(--ccache "$CCACHE_SIZE")
args+=(--cmake-generator "$CMAKE_GENERATOR")
RUN --server --debug --test "${args[@]}"
- name: "Build release package"
id: build
@ -269,7 +273,7 @@ jobs:
override_target: 13
package_suffix: "-macOS13_Intel"
qt_version: 6.11.1
qt_modules: qtimageformats qtmultimedia qtwebsockets
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
soc: Intel
type: Release
use_ccache: 1
@ -285,7 +289,7 @@ jobs:
override_target: 14
package_suffix: "-macOS14"
qt_version: 6.11.1
qt_modules: qtimageformats qtmultimedia qtwebsockets
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
soc: Apple
type: Release
use_ccache: 1
@ -301,7 +305,7 @@ jobs:
override_target: 15
package_suffix: "-macOS15"
qt_version: 6.11.1
qt_modules: qtimageformats qtmultimedia qtwebsockets
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
soc: Apple
type: Release
use_ccache: 1
@ -314,7 +318,7 @@ jobs:
ccache_eviction_age: 7d
cmake_generator: Ninja
qt_version: 6.11.1
qt_modules: qtimageformats qtmultimedia qtwebsockets
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
soc: Apple
type: Debug
use_ccache: 1
@ -329,7 +333,7 @@ jobs:
make_package: 1
package_suffix: "-Win10"
qt_version: 6.11.1
qt_modules: qtimageformats qtmultimedia qtwebsockets
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
type: Release
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }}

View file

@ -127,7 +127,7 @@ jobs:
steps:
- name: "Download digests"
uses: actions/download-artifact@v7
uses: actions/download-artifact@v8
with:
path: ${{ runner.temp }}/digests
pattern: digest-*

View file

@ -5,23 +5,23 @@
# This file sets all the variables shared between the projects
# like the installation path, compilation flags etc..
# cmake 3.16 is required if using qt6
cmake_minimum_required(VERSION 3.10)
# 3.16 required for Qt6 and target_precompile_headers()
cmake_minimum_required(VERSION 3.16)
# Early detect ccache
# Use compiler cache (ccache)
option(USE_CCACHE "Cache the build results with ccache" ON)
# Treat warnings as errors (Debug builds only)
option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON)
# Check for translation updates
option(UPDATE_TRANSLATIONS "Update translations on compile" OFF)
# Compile servatrice
option(WITH_SERVER "build servatrice" OFF)
# Compile cockatrice
option(WITH_CLIENT "build cockatrice" ON)
# Compile oracle
option(WITH_ORACLE "build oracle" ON)
# Compile Cockatrice
option(WITH_CLIENT "Build Cockatrice client" ON)
# Compile Oracle
option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON)
# Compile Servatrice
option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF)
# Compile tests
option(TEST "build tests" OFF)
option(TEST "Build tests" OFF)
# Use vcpkg regardless of OS
option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
@ -39,13 +39,24 @@ else()
)
endif()
if(USE_CCACHE)
# ccache does not support MSVC and must not auto-engage on Windows
# (it is installed unintentionally on the Windows CI runner).
# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows
# also opts out of ccache even though the GNUCXX branch below supports it.
if(USE_CCACHE AND NOT WIN32)
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
# Support Unix Makefiles and Ninja
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}")
# PCH-aware caching, matching .ci/compile.sh: without this ccache refuses
# to cache any TU that consumes a precompiled header, so every PCH-backed
# target recompiles from scratch on each build.
execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
message(STATUS "Found CCache ${CCACHE_PROGRAM}")
endif()
elseif(USE_CCACHE AND WIN32)
# An explicit opt-in must not disappear silently on Windows.
message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows")
endif()
if(WIN32 OR USE_VCPKG)
@ -184,6 +195,9 @@ elseif(CMAKE_COMPILER_IS_GNUCXX)
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}")
endif()
endforeach()
# Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
else()
# other: osx/llvm, bsd/llvm
set(CMAKE_CXX_FLAGS_RELEASE "-O2")
@ -192,6 +206,9 @@ else()
else()
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra")
endif()
# Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
endif()
# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning
@ -239,11 +256,6 @@ if(WIN32)
find_package(OpenSSL REQUIRED)
if(OPENSSL_FOUND)
include_directories(${OPENSSL_INCLUDE_DIRS})
else()
message(
WARNING
"Could not find OpenSSL runtime libraries. They are not required for compiling, but needs to be available at runtime."
)
endif()
endif()

View file

@ -14,6 +14,7 @@ RUN apt-get update \
libmariadb-dev-compat \
libprotobuf-dev \
libqt6sql6-mysql \
libssl-dev \
qt6-websockets-dev \
protobuf-compiler \
qt6-tools-dev \
@ -42,6 +43,7 @@ RUN apt-get update \
libprotobuf32t64 \
libqt6sql6-mysql \
libqt6websockets6 \
libssl3 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

View file

@ -18,10 +18,13 @@ if(WITH_CLIENT)
Multimedia
Network
PrintSupport
ShaderTools
Svg
WebSockets
Widgets
Xml
Quick
QuickWidgets
)
endif()
if(WITH_ORACLE)

24
cmake/pch/qtcore_pch.h Normal file
View file

@ -0,0 +1,24 @@
/** @file qtcore_pch.h
* @brief Precompiled header for all Qt targets (Qt Core only).
*
* Safe for every target that links Qt Core, including the headless
* Servatrice binary. Keep this header free of any widget/gui types.
*/
#include <QBasicTimer>
#include <QByteArray>
#include <QDateTime>
#include <QDebug>
#include <QFile>
#include <QHash>
#include <QList>
#include <QLoggingCategory>
#include <QMap>
#include <QMetaObject>
#include <QObject>
#include <QRandomGenerator>
#include <QSharedPointer>
#include <QString>
#include <QStringList>
#include <QTimer>
#include <QVariant>

30
cmake/pch/qtwidgets_pch.h Normal file
View file

@ -0,0 +1,30 @@
/** @file qtwidgets_pch.h
* @brief Precompiled header for GUI targets (Cockatrice client, Oracle).
*
* Includes the Qt Core precompiled header plus the heavy Gui, Widgets and
* Network layers that virtually every client translation unit re-parses.
* Do not use on Servatrice (headless, QT_DONT_USE_QTGUI).
*/
#include "qtcore_pch.h"
#include <QAction>
#include <QApplication>
#include <QFrame>
#include <QGraphicsItem>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QImage>
#include <QLabel>
#include <QLayout>
#include <QMainWindow>
#include <QMenu>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QPainter>
#include <QPushButton>
#include <QScrollArea>
#include <QTabWidget>
#include <QToolBar>
#include <QTreeWidget>
#include <QWidget>

View file

@ -214,6 +214,7 @@ set(cockatrice_SOURCES
src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp
src/interface/widgets/deck_editor/deck_list_style_proxy.cpp
src/interface/widgets/deck_editor/deck_state_manager.cpp
src/interface/widgets/deck_editor/deck_zone_dialog.cpp
src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp
src/interface/widgets/general/background_sources.cpp
src/interface/widgets/general/display/background_plate_widget.cpp
@ -230,6 +231,7 @@ set(cockatrice_SOURCES
src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp
src/interface/widgets/general/display/charts/pies/color_pie.cpp
src/interface/widgets/general/home_styled_button.cpp
src/interface/widgets/general/home_tab_button_color.h
src/interface/widgets/general/home_widget.cpp
src/interface/widgets/general/layout_containers/flow_widget.cpp
src/interface/widgets/general/layout_containers/overlap_control_widget.cpp
@ -270,6 +272,7 @@ set(cockatrice_SOURCES
src/interface/widgets/server/user/user_context_menu.cpp
src/interface/widgets/server/user/user_info_box.cpp
src/interface/widgets/server/user/user_info_connection.cpp
src/interface/widgets/server/user/user_list_dialog.cpp
src/interface/widgets/server/user/user_list_manager.cpp
src/interface/widgets/server/user/user_list_painter.cpp
src/interface/widgets/server/user/user_list_panel_widget.cpp
@ -397,6 +400,27 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp
src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp
src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp
src/interface/widgets/onboarding/banner_shader_config.h
src/interface/widgets/onboarding/first_run_wizard.cpp
src/interface/widgets/onboarding/first_run_wizard.h
src/interface/widgets/onboarding/first_run_wizard_page.cpp
src/interface/widgets/onboarding/first_run_wizard_page.h
src/interface/widgets/onboarding/pages/account_setup_page.cpp
src/interface/widgets/onboarding/pages/account_setup_page.h
src/interface/widgets/onboarding/pages/card_database_setup_page.cpp
src/interface/widgets/onboarding/pages/card_database_setup_page.h
src/interface/widgets/onboarding/pages/finish_page.cpp
src/interface/widgets/onboarding/pages/finish_page.h
src/interface/widgets/onboarding/pages/preferences_setup_page.cpp
src/interface/widgets/onboarding/pages/preferences_setup_page.h
src/interface/widgets/onboarding/pages/theme_setup_page.cpp
src/interface/widgets/onboarding/pages/theme_setup_page.h
src/interface/widgets/onboarding/pages/welcome_page.cpp
src/interface/widgets/onboarding/pages/welcome_page.h
src/interface/widgets/onboarding/shader_banner_widget.cpp
src/interface/widgets/onboarding/shader_banner_widget.h
src/interface/widgets/onboarding/step_indicator_widget.cpp
src/interface/widgets/onboarding/step_indicator_widget.h
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp
@ -493,6 +517,30 @@ qt6_add_executable(
MANUAL_FINALIZATION
)
target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
qt6_add_shaders(
cockatrice
"onboarding_shaders"
PREFIX
"/onboarding/shaders"
BASE
"src/interface/widgets/onboarding/shaders"
FILES
src/interface/widgets/onboarding/shaders/brand_banner.frag
)
qt6_add_resources(
cockatrice
"onboarding_qml"
PREFIX
"/onboarding/qml"
BASE
"src/interface/widgets/onboarding/qml"
FILES
src/interface/widgets/onboarding/qml/BrandBanner.qml
)
target_link_libraries(
cockatrice
PUBLIC libcockatrice_card

View file

@ -2,6 +2,7 @@
<qresource prefix="/">
<file>resources/cardback.svg</file>
<file>resources/cockatrice.svg</file>
<file>resources/cockatrice-logo-white.svg</file>
<file>resources/hand.svg</file>
<file>resources/hr.jpg</file>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Before After
Before After

View file

@ -52,6 +52,7 @@ In this list of examples below, each entry has an explanation and can be clicked
<dt><u>E</u>dition:</dt>
<dd>[set:lea](#set:lea) <small>(Cards that appear in Alpha, which has the set code LEA)</small></dd>
<dd>[e:lea OR e:leb](#e:lea OR e:leb) <small>(Cards that appear in Alpha or Beta)</small></dd>
<dd>[e&lt;8ED](#e<8ED) <small>(Cards that appear before 8th edition)</small></dd>
<dt>Negate:</dt>
<dd>[c:wu -c:m](#c:wu -c:m) <small>(Any card that is white or blue, but not multicolored)</small></dd>

View file

@ -10,7 +10,6 @@
#include <algorithm>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/settings/cards_display_settings.h>
DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item,
@ -381,12 +380,10 @@ void DeckViewScene::rebuildTree()
addItem(container);
}
for (int j = 0; j < currentZone->size(); j++) {
auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j));
if (!currentCard) {
continue;
}
// Cards in custom zones nested under a board are regular board cards in-game.
// They are collected recursively (like every other consumer) and reported with
// the top-level board zone as their origin, so that sideboard plans keep working.
for (auto *currentCard : deck->getCardNodes({currentZone->getName()})) {
for (int k = 0; k < currentCard->getNumber(); ++k) {
auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName());
container->addCard(newCard);

View file

@ -9,6 +9,7 @@
#include "../../interface/widgets/dialogs/dlg_load_deck_from_website.h"
#include "../../interface/widgets/dialogs/dlg_load_remote_deck.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "../../interface/widgets/visual_deck_storage/visual_deck_storage_widget.h"
#include "deck_view.h"
#include <QMessageBox>

View file

@ -44,11 +44,16 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
GameScene::~GameScene()
{
// Sever all incoming connections (animated item destroy-tracking) before the
// members below are destroyed: the base QGraphicsScene destructor destroys the
// remaining items, and their destroyed() signals must not reach slots that
// reference members that no longer exist.
disconnect(this);
// Sever all destroyed->removeAnimatedItem connections before the members below
// are destroyed: the base QGraphicsScene destructor destroys the remaining items,
// and their destroyed() signals must not reach slots that reference members that
// no longer exist. The connection handle overload is used because the string-based
// disconnect(nullptr, nullptr, this, nullptr) is invalid (the sender must never be
// nullptr) and would otherwise fail to sever these pointer-to-member connections.
for (auto it = animationItemConnections.constBegin(); it != animationItemConnections.constEnd(); ++it) {
QObject::disconnect(*it);
}
animationItemConnections.clear();
delete animationTimer;
animationTimer = nullptr;
@ -777,8 +782,15 @@ void GameScene::registerAnimationItem(IAnimatedItem *item)
if (!object) {
return;
}
if (!animatedItems.contains(object)) {
connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem);
// Guard against duplicate connections using the connection map, not
// animatedItems: the animation timer removes entries from animatedItems when an
// animation completes, but the destroyed->removeAnimatedItem connection must
// persist until the object is destroyed. Relying on animatedItems here would let
// a re-registered item (e.g. a life counter that flashes repeatedly) accumulate
// duplicate destroyed connections, the older ones of which would survive teardown.
if (!animationItemConnections.contains(object)) {
animationItemConnections.insert(object,
connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem));
}
animatedItems.insert(object, item);
if (animationTimer && !animationTimer->isActive()) {
@ -797,6 +809,7 @@ void GameScene::unregisterAnimationItem(IAnimatedItem *item)
void GameScene::removeAnimatedItem(QObject *item)
{
animatedItems.remove(item);
animationItemConnections.remove(item);
if (animationTimer && animatedItems.isEmpty()) {
animationTimer->stop();
}

View file

@ -54,9 +54,11 @@ private:
QPointer<CardItem> hoveredCard; ///< Currently hovered card
QBasicTimer *animationTimer; ///< Timer for scene animations
QHash<QObject *, IAnimatedItem *> animatedItems; ///< Items currently animating
int playerRotation; ///< Rotation offset for player layout
bool rearranging = false; ///< Guard against re-entrant rearrange
bool needsReArrange = false; ///< Pending rearrange requested during a pass
QHash<QObject *, QMetaObject::Connection>
animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item
int playerRotation; ///< Rotation offset for player layout
bool rearranging = false; ///< Guard against re-entrant rearrange
bool needsReArrange = false; ///< Pending rearrange requested during a pass
/**
* @brief Updates which card is currently hovered based on scene coordinates.

View file

@ -12,11 +12,13 @@ TallyMenu::TallyMenu()
aTallyNone = createTallyAction(TallyType::None);
aTallySubtypes = createTallyAction(TallyType::Subtypes);
aTallyTotalPower = createTallyAction(TallyType::TotalPower);
aTallyTotalToughness = createTallyAction(TallyType::TotalToughness);
addAction(aTallyNone);
addSeparator();
addAction(aTallySubtypes);
addAction(aTallyTotalPower);
addAction(aTallyTotalToughness);
retranslateUi();
}
@ -54,4 +56,5 @@ void TallyMenu::retranslateUi()
aTallyNone->setText(tr("None"));
aTallySubtypes->setText(tr("Subtypes"));
aTallyTotalPower->setText(tr("Total Power"));
aTallyTotalToughness->setText(tr("Total Toughness"));
}

View file

@ -24,6 +24,7 @@ private:
QAction *aTallyNone = nullptr;
QAction *aTallySubtypes = nullptr;
QAction *aTallyTotalPower = nullptr;
QAction *aTallyTotalToughness = nullptr;
QAction *createTallyAction(TallyType tallyType);
};

View file

@ -34,3 +34,31 @@ QList<TallyRow> StatsTally::computeTotalPower(const QList<CardItem *> &cards)
QString name = QCoreApplication::translate("StatsTally", "Total Power");
return {TallyRow{name, QString::number(total)}};
}
static int sumToughness(const QList<CardItem *> &cards)
{
int total = 0;
for (auto card : cards) {
QVariantList parsed = CardItem::parsePT(card->getPT());
if (parsed.size() == 2) {
int toughness = parsed.at(1).toInt(); // toInt will default to 0 if it's not an int
total += qMax(toughness, 0);
}
}
return total;
}
QList<TallyRow> StatsTally::computeTotalToughness(const QList<CardItem *> &cards)
{
// don't bother if none of the cards have pt
bool hasPT =
std::any_of(cards.cbegin(), cards.cend(), [](const CardItem *card) { return !card->getPT().isEmpty(); });
if (!hasPT) {
return {};
}
int total = sumToughness(cards);
QString name = QCoreApplication::translate("StatsTally", "Total Toughness");
return {TallyRow{name, QString::number(total)}};
}

View file

@ -16,6 +16,14 @@ namespace StatsTally
*/
QList<TallyRow> computeTotalPower(const QList<CardItem *> &cards);
/**
* @brief Sums the toughness of all selected cards
*
* @param cards The list of selected card items to analyze.
* @return A single row containing the total, or an empty list if none of the cards have pt
*/
QList<TallyRow> computeTotalToughness(const QList<CardItem *> &cards);
} // namespace StatsTally
#endif // COCKATRICE_STATS_TALLY_H

View file

@ -21,6 +21,8 @@ QList<TallyRow> Tally::compute(const QList<CardItem *> &cards, const TallyType t
return SubtypeTally::countSubtypes(cards);
case TallyType::TotalPower:
return StatsTally::computeTotalPower(cards);
case TallyType::TotalToughness:
return StatsTally::computeTotalToughness(cards);
}
return {};
}

View file

@ -21,7 +21,8 @@ enum class TallyType
None,
Subtypes,
TotalPower,
MaxValue = TotalPower // sentinel value
TotalToughness,
MaxValue = TotalToughness // sentinel value
};
namespace Tally

View file

@ -41,7 +41,8 @@ void HandZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
}
}
} else {
x = calcDropIndexFromY(dropPoint.y());
bool sameZone = startZone == getLogic();
x = calcDropIndexFromY(dropPoint.y(), !sameZone);
}
Command_MoveCard cmd;

View file

@ -83,7 +83,7 @@ SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) cons
return {cardCount, boundingRect().height(), cardHeight, offset, minOffset};
}
int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const
int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset) const
{
const auto &cards = getLogic()->getCards();
if (cards.isEmpty()) {
@ -94,7 +94,8 @@ int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const
if (effectiveOffset <= 0.0) {
return 0;
}
return qBound(0, qRound((dropY - start) / effectiveOffset), params.cardCount - 1);
int max = allowCountExpand ? params.cardCount : params.cardCount - 1;
return qBound(0, qRound((dropY - start) / effectiveOffset), max);
}
void SelectZone::restoreStaleEscapedCards()

View file

@ -104,8 +104,12 @@ protected:
/**
* @brief Computes the card index at a given y-coordinate within the zone's vertical layout.
* Returns 0 if the zone has no cards or the offset is zero.
*
* @param dropY The y-coordinate that the card was dropped at
* @param allowCountExpand If false, clamps the index at the number of cards minus 1
* @param minOffset Minimum offset to preserve
*/
int calcDropIndexFromY(qreal dropY, qreal minOffset = 0.0) const;
int calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset = 0.0) const;
/**
* @brief Positions cards vertically with alternating left/right x-offsets.

View file

@ -57,18 +57,14 @@ void StackZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
return;
}
const auto &cards = getLogic()->getCards();
int index;
if (startZone == getLogic()) {
// Reordering within the zone: use drop position
index = calcDropIndexFromY(dropPoint.y(), MIN_CARD_VISIBLE);
bool sameZone = startZone == getLogic();
int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE);
if (sameZone) {
// Same-zone no-op: don't move a card onto itself
const auto &cards = getLogic()->getCards();
if (!cards.isEmpty() && cards.at(index)->getId() == dragItems.at(0)->getId()) {
return;
}
} else {
// Coming from another zone: append at end (top of stack, rendered on top)
index = static_cast<int>(cards.size());
}
Command_MoveCard cmd;

View file

@ -375,15 +375,32 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL
void DeckLoader::saveToStream_DeckZone(QTextStream &out,
const InnerDecklistNode *zoneNode,
bool addComments,
bool addSetNameAndNumber)
bool addSetNameAndNumber,
const QString &boardZoneName)
{
// Nested sub-zones keep their owning board's identity: the top-level call
// passes no board, so the zone's own name is used; recursive calls carry the
// owning board down so the sideboard marker survives sub-zone nesting.
const QString owningBoardZoneName = boardZoneName.isEmpty() ? zoneNode->getName() : boardZoneName;
// group cards by card type and count the subtotals
QMultiMap<QString, DecklistCardNode *> cardsByType;
QMap<QString, int> cardTotalByType;
int cardTotal = 0;
QList<const InnerDecklistNode *> subZones;
for (int j = 0; j < zoneNode->size(); j++) {
auto *card = dynamic_cast<DecklistCardNode *>(zoneNode->at(j));
if (!card) {
// Cards collected in nested sub-zones are exported by recursion so
// they don't end up invisible in the plain text output. They are
// deferred until after this zone's own header and cards so they read
// as part of this zone's block.
if (auto *subZone = dynamic_cast<const InnerDecklistNode *>(zoneNode->at(j))) {
subZones.append(subZone);
}
continue;
}
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName());
QString cardType = info ? info->getMainCardType() : "unknown";
@ -411,25 +428,30 @@ void DeckLoader::saveToStream_DeckZone(QTextStream &out,
QList<DecklistCardNode *> cards = cardsByType.values(cardType);
saveToStream_DeckZoneCards(out, zoneNode, cards, addComments, addSetNameAndNumber);
saveToStream_DeckZoneCards(out, cards, addComments, addSetNameAndNumber, owningBoardZoneName);
if (addComments) {
out << "\n";
}
}
// Nested sub-zones come last, after the parent's own header and cards.
for (const auto *subZone : subZones) {
saveToStream_DeckZone(out, subZone, addComments, addSetNameAndNumber, owningBoardZoneName);
}
}
void DeckLoader::saveToStream_DeckZoneCards(QTextStream &out,
const InnerDecklistNode *zoneNode,
QList<DecklistCardNode *> cards,
bool addComments,
bool addSetNameAndNumber)
bool addSetNameAndNumber,
const QString &boardZoneName)
{
// QMultiMap sorts values in reverse order
for (int i = cards.size() - 1; i >= 0; --i) {
DecklistCardNode *card = cards[i];
if (zoneNode->getName() == DECK_ZONE_SIDE && addComments) {
if (boardZoneName == DECK_ZONE_SIDE && addComments) {
out << "SB: ";
}
@ -510,9 +532,26 @@ bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck)
void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node)
{
if (!node || node->isEmpty()) {
return;
}
const int totalColumns = 2;
if (node->height() == 1) {
// Dispatch children by type instead of trusting a whole-node height: a deck
// node may hold direct cards and nested zones side by side (custom zones),
// and an empty node would previously crash on at(0).
QVector<const AbstractDecklistCardNode *> cards;
QVector<const InnerDecklistNode *> subZones;
for (int i = 0; i < node->size(); i++) {
if (auto *card = dynamic_cast<const AbstractDecklistCardNode *>(node->at(i))) {
cards.append(card);
} else if (auto *zone = dynamic_cast<const InnerDecklistNode *>(node->at(i))) {
subZones.append(zone);
}
}
if (!cards.isEmpty()) {
QTextBlockFormat blockFormat;
QTextCharFormat charFormat;
charFormat.setFontPointSize(11);
@ -523,9 +562,9 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
tableFormat.setCellPadding(0);
tableFormat.setCellSpacing(0);
tableFormat.setBorder(0);
QTextTable *table = cursor->insertTable(node->size() + 1, totalColumns, tableFormat);
for (int i = 0; i < node->size(); i++) {
auto *card = dynamic_cast<AbstractDecklistCardNode *>(node->at(i));
QTextTable *table = cursor->insertTable(cards.size() + 1, totalColumns, tableFormat);
for (int i = 0; i < cards.size(); i++) {
const AbstractDecklistCardNode *card = cards[i];
QTextCharFormat cellCharFormat;
cellCharFormat.setFontPointSize(9);
@ -540,7 +579,13 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
cellCursor = cell.firstCursorPosition();
cellCursor.insertText(card->getName());
}
} else if (node->height() == 2) {
}
for (const InnerDecklistNode *subZone : subZones) {
if (subZone->isEmpty()) {
continue;
}
QTextBlockFormat blockFormat;
QTextCharFormat charFormat;
charFormat.setFontPointSize(14);
@ -559,10 +604,8 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
tableFormat.setColumnWidthConstraints(constraints);
QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat);
for (int i = 0; i < node->size(); i++) {
QTextCursor cellCursor = table->cellAt(0, (i * totalColumns) / node->size()).lastCursorPosition();
printDeckListNode(&cellCursor, dynamic_cast<InnerDecklistNode *>(node->at(i)));
}
QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition();
printDeckListNode(&cellCursor, subZone);
}
cursor->movePosition(QTextCursor::End);

View file

@ -159,12 +159,13 @@ private:
static void saveToStream_DeckZone(QTextStream &out,
const InnerDecklistNode *zoneNode,
bool addComments = true,
bool addSetNameAndNumber = true);
bool addSetNameAndNumber = true,
const QString &boardZoneName = QString());
static void saveToStream_DeckZoneCards(QTextStream &out,
const InnerDecklistNode *zoneNode,
QList<DecklistCardNode *> cards,
bool addComments = true,
bool addSetNameAndNumber = true);
bool addSetNameAndNumber = true,
const QString &boardZoneName = QString());
};
#endif

View file

@ -290,28 +290,42 @@ void PaletteEditorDialog::onSave()
// Persist every scheme that changed, not just the one on screen. Each scheme
// has its own file, so edits to the non-active scheme would otherwise be
// silently discarded when the dialog closes.
//
// Save the loaded scheme last so commitPalette's global colour-scheme
// update (ThemeConfig::colorScheme) points at the active scheme.
for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) {
const QString &scheme = it.key();
if (it.value().colors == savedConfig.value(scheme).colors) {
continue; // unchanged — leave the on-disk file alone
if (it.key() == loadedScheme) {
continue;
}
if (!ThemeManager::savePaletteConfig(saveDir, scheme, it.value())) {
if (it.value().colors == savedConfig.value(it.key()).colors) {
continue;
}
if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) {
QMessageBox::warning(this, tr("Save failed"),
tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(scheme), saveDir));
tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(it.key()), saveDir));
return;
}
}
// Commit the active scheme last so the global colour scheme matches.
if (workingConfig[loadedScheme].colors != savedConfig.value(loadedScheme).colors) {
if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) {
QMessageBox::warning(this, tr("Save failed"),
tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir));
return;
}
} else {
// No palette change but scheme may have switched -- still update global config.
ThemeConfig globalCfg = ThemeConfig::fromThemeDir(saveDir);
globalCfg.colorScheme = loadedScheme;
globalCfg.save(saveDir);
}
// Keep the saved snapshot in sync so Reset behaves correctly afterwards.
for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) {
savedConfig[it.key()] = it.value();
}
ThemeConfig globalCfg = ThemeConfig::fromThemeDir(saveDir);
globalCfg.colorScheme = loadedScheme;
globalCfg.save(saveDir);
themeManager->reloadCurrentTheme();
accept();
}

View file

@ -3,6 +3,7 @@
#include <QApplication>
#include <QDomDocument>
#include <QFile>
#include <QImageReader>
#include <QPainter>
#include <QPalette>
#include <QSvgRenderer>
@ -418,6 +419,57 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded)
QMap<QString, QPixmap> DropdownIconPixmapGenerator::pmCache;
namespace
{
/// Longest side mana symbols are rendered at before being scaled to their final size.
constexpr int MASTER_ICON_SIZE = 128;
QString manaSymbolCacheKey(const QString &symbol, const QSize &size)
{
return symbol + QLatin1Char('|') + QString::number(size.width()) + QLatin1Char('x') +
QString::number(size.height());
}
} // namespace
const QPixmap &ManaSymbolPixmapGenerator::masterIcon(const QString &symbol)
{
auto it = masterCache.constFind(symbol);
if (it != masterCache.constEnd()) {
return it.value();
}
QImageReader reader("theme:icons/mana/" + symbol);
QSize sourceSize = reader.size();
if (!sourceSize.isEmpty()) {
sourceSize.scale(QSize(MASTER_ICON_SIZE, MASTER_ICON_SIZE), Qt::KeepAspectRatio);
reader.setScaledSize(sourceSize);
}
const QPixmap rendered = QPixmap::fromImageReader(&reader);
return masterCache.insert(symbol, rendered).value();
}
QPixmap ManaSymbolPixmapGenerator::generatePixmap(const QString &symbol, const QSize &size)
{
const QString key = manaSymbolCacheKey(symbol, size);
auto it = scaledCache.constFind(key);
if (it != scaledCache.constEnd()) {
return it.value();
}
const QPixmap &icon = masterIcon(symbol);
if (icon.isNull()) {
return {};
}
QPixmap scaled = icon.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
scaledCache.insert(key, scaled);
return scaled;
}
QHash<QString, QPixmap> ManaSymbolPixmapGenerator::masterCache;
QHash<QString, QPixmap> ManaSymbolPixmapGenerator::scaledCache;
QPixmap loadColorAdjustedPixmap(const QString &name)
{
if (qApp->palette().windowText().color().lightness() > 200) {

View file

@ -7,6 +7,7 @@
#ifndef PIXMAPGENERATOR_H
#define PIXMAPGENERATOR_H
#include <QHash>
#include <QIcon>
#include <QLoggingCategory>
#include <QMap>
@ -125,6 +126,34 @@ public:
}
};
class ManaSymbolPixmapGenerator
{
private:
static QHash<QString, QPixmap> masterCache;
static QHash<QString, QPixmap> scaledCache;
/**
* @brief Renders \a symbol once at a fixed moderate size, so repeated scalings never
* re-rasterize the source file (SVG sources can be very expensive to rasterize).
*/
static const QPixmap &masterIcon(const QString &symbol);
public:
/**
* @brief Returns a smooth-scaled rendering of the given mana symbol icon.
*
* Results are shared between all callers via a process-wide cache keyed by symbol
* and size, so scaling work is done once per distinct combination instead of once
* per widget creation or resize.
*/
static QPixmap generatePixmap(const QString &symbol, const QSize &size);
static void clear()
{
masterCache.clear();
scaledCache.clear();
}
};
QPixmap loadColorAdjustedPixmap(const QString &name);
#endif

View file

@ -272,6 +272,19 @@ PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath
return cfg;
}
bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg)
{
if (!savePaletteConfig(themeDirPath, colorScheme, cfg)) {
return false;
}
ThemeConfig globalCfg = ThemeConfig::fromThemeDir(themeDirPath);
globalCfg.colorScheme = colorScheme;
globalCfg.save(themeDirPath);
return true;
}
void ThemeManager::setColorScheme(const QString &scheme)
{
const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName());

View file

@ -91,6 +91,10 @@ public:
// theme directory when it is absent from the resolved (user) directory.
static PaletteConfig
loadDefaultPaletteConfig(const QString &themeDirPath, const QString &themeName, const QString &colorScheme);
/** @brief Writes cfg to disk as the theme's palette-<scheme>.toml and updates the
* theme's stored colour scheme to match. Shared by PaletteEditorDialog::onSave
* and FirstRunWizard's theme step so the two "generate + keep" paths can't drift. */
static bool commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg);
void setColorScheme(const QString &scheme);
void setStyleName(const QString &styleName);

View file

@ -41,6 +41,11 @@ void ColorIdentityWidget::populateManaSymbolWidgets()
// clear old layout
QtUtils::clearLayoutRec(layout);
// The freshly created symbols haven't been sized yet, so force the next resize pass
// to apply the symbol size again.
lastIconSize = -1;
lastWidth = -1;
// populate mana symbols
if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities()) {
for (const QString symbol : fullColorIdentity) {
@ -73,20 +78,33 @@ void ColorIdentityWidget::toggleUnusedVisibility()
void ColorIdentityWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
QList<ManaSymbolWidget *> manaSymbols = findChildren<ManaSymbolWidget *>();
if (!manaSymbols.isEmpty()) {
int totalWidth = event->size().width();
int totalHeight = totalWidth / 6; // Set height to 1/4 of the width
setFixedHeight(totalHeight);
const int totalWidth = event->size().width();
if (totalWidth == lastWidth && lastIconSize != -1) {
return;
}
lastWidth = totalWidth;
int spacing = layout->spacing();
int count = manaSymbols.size();
int availableWidth = totalWidth - (spacing * (count - 1));
int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height
const int totalHeight = totalWidth / 6; // Set height to 1/4 of the width
setFixedHeight(totalHeight);
for (ManaSymbolWidget *manaSymbol : manaSymbols) {
manaSymbol->setFixedSize(iconSize, iconSize);
const int count = layout->count();
if (count == 0) {
return;
}
const int spacing = layout->spacing();
const int availableWidth = totalWidth - (spacing * (count - 1));
const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height
if (iconSize == lastIconSize) {
return;
}
lastIconSize = iconSize;
for (int i = 0; i < count; ++i) {
if (auto *w = qobject_cast<ManaSymbolWidget *>(layout->itemAt(i)->widget())) {
w->setFixedSize(iconSize, iconSize);
}
}
}

View file

@ -30,6 +30,8 @@ public slots:
private:
QString colorIdentity;
QHBoxLayout *layout;
int lastIconSize = -1; ///< The symbol size last applied, to skip redundant resize passes.
int lastWidth = -1; ///< The width last processed, to skip redundant resize passes.
};
#endif // COLOR_IDENTITY_WIDGET_H

View file

@ -1,15 +1,15 @@
#include "mana_symbol_widget.h"
#include "../../../../client/settings/cache_settings.h"
#include "../../../pixel_map_generator.h"
#include <QResizeEvent>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
ManaSymbolWidget::ManaSymbolWidget(QWidget *parent, QString _symbol, bool _isActive, bool _mayBeToggled)
: QLabel(parent), symbol(_symbol), isActive(_isActive), mayBeToggled(_mayBeToggled)
: QLabel(parent), symbol(std::move(_symbol)), isActive(_isActive), mayBeToggled(_mayBeToggled)
{
loadManaIcon();
setPixmap(manaIcon.scaled(50, 50, Qt::KeepAspectRatio, Qt::SmoothTransformation));
setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(50, 50)));
setMaximumWidth(50);
// Initialize opacity effect
@ -64,16 +64,13 @@ void ManaSymbolWidget::mousePressEvent(QMouseEvent *event)
void ManaSymbolWidget::resizeEvent(QResizeEvent *event)
{
QLabel::resizeEvent(event);
setPixmap(manaIcon.scaled(event->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
const QSize newSize = event->size();
void ManaSymbolWidget::loadManaIcon()
{
QString filename = "theme:icons/mana/";
if (symbol == "W" || symbol == "U" || symbol == "B" || symbol == "R" || symbol == "G") {
filename += symbol;
// Skip the rescale when the size didn't actually change: layout passes resize these
// widgets repeatedly with identical sizes.
if (newSize.isEmpty() || pixmap().size() == newSize) {
return;
}
manaIcon = QPixmap(filename);
setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, newSize));
}

View file

@ -33,8 +33,6 @@ public:
return symbol[0];
}
void loadManaIcon();
public slots:
void resizeEvent(QResizeEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
@ -44,7 +42,6 @@ signals:
private:
QString symbol;
QPixmap manaIcon;
bool isActive;
bool mayBeToggled;
QGraphicsOpacityEffect *opacityEffect;

View file

@ -174,16 +174,18 @@ void CardGroupDisplayWidget::updateCardDisplays()
QModelIndex sourceIndex = proxy.mapToSource(proxyIndex);
// 4. persist the source index
QPersistentModelIndex persistent(sourceIndex);
addCardWidgets(QPersistentModelIndex(sourceIndex));
}
}
// Get the card amount
int cardAmount =
sourceIndex.sibling(sourceIndex.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent)
{
// Get the card amount
int cardAmount = persistent.sibling(persistent.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
// Create multiple widgets for the card count
for (int copy = 0; copy < cardAmount; ++copy) {
addToLayout(constructWidgetForIndex(persistent));
}
// Create multiple widgets for the card count
for (int copy = 0; copy < cardAmount; ++copy) {
addToLayout(constructWidgetForIndex(persistent));
}
}

View file

@ -35,6 +35,7 @@ public:
void onSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected);
void refreshSelectionForIndex(const QPersistentModelIndex &persistent);
void clearAllDisplayWidgets();
void addCardWidgets(const QPersistentModelIndex &persistent);
DeckListModel *deckListModel;
QItemSelectionModel *selectionModel;

View file

@ -5,6 +5,7 @@
#include "libcockatrice/card/database/card_database_manager.h"
#include <QResizeEvent>
#include <algorithm>
#include <libcockatrice/models/deck_list/deck_list_model.h>
DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
@ -51,11 +52,6 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
// User Interaction
// =====================================================================================================================
void DeckCardZoneDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card)
{
emit cardClicked(event, card, zoneName);
}
void DeckCardZoneDisplayWidget::onHover(const ExactCard &card)
{
emit cardHovered(card);
@ -95,12 +91,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
}
auto categoryName = index.sibling(index.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
// Cards in a custom zone belong to that zone, not the board zone, so that
// increment/decrement/swap actions target the custom zone.
const bool isCustomZone = index.data(DeckRoles::IsCustomZoneRole).toBool();
const QString effectiveZoneName = isCustomZone ? categoryName : zoneName;
const auto routeCardClick = [this, effectiveZoneName](QMouseEvent *event, const ExactCard &card) {
emit cardClicked(event, card, effectiveZoneName);
};
if (displayType == DisplayType::Overlap) {
auto *displayWidget = new OverlappedCardGroupDisplayWidget(
cardGroupContainer, deckListModel, selectionModel, index, zoneName, categoryName, activeGroupCriteria,
activeSortCriteria, subBannerOpacity, cardSizeWidget);
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this,
&DeckCardZoneDisplayWidget::onClick);
cardGroupContainer, deckListModel, selectionModel, index, effectiveZoneName, categoryName,
activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget);
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, routeCardClick);
connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this,
&DeckCardZoneDisplayWidget::onHover);
connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this,
@ -111,9 +113,9 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
indexToWidgetMap.insert(index, displayWidget);
} else if (displayType == DisplayType::Flat) {
auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index,
zoneName, categoryName, activeGroupCriteria,
effectiveZoneName, categoryName, activeGroupCriteria,
activeSortCriteria, subBannerOpacity, cardSizeWidget);
connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, &DeckCardZoneDisplayWidget::onClick);
connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, routeCardClick);
connect(displayWidget, &FlatCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover);
connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this,
&DeckCardZoneDisplayWidget::cleanupInvalidCardGroup);
@ -126,24 +128,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex
void DeckCardZoneDisplayWidget::displayCards()
{
QSortFilterProxyModel proxy;
proxy.setSourceModel(deckListModel);
proxy.setSortRole(Qt::EditRole);
proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder);
if (!trackedIndex.isValid()) {
return;
}
// 1. trackedIndex is a source index → map it to proxy space
QModelIndex proxyParent = proxy.mapFromSource(trackedIndex);
// 2. iterate children under the proxy parent
for (int i = 0; i < proxy.rowCount(proxyParent); ++i) {
QModelIndex proxyIndex = proxy.index(i, 0, proxyParent);
// 3. map back to source
QModelIndex sourceIndex = proxy.mapToSource(proxyIndex);
// 4. persist the source index
QPersistentModelIndex persistent(sourceIndex);
// Iterate the direct children of the tracked zone, keeping the tree view's row
// order (criteria groups first, then custom zones, both in the model's sort order).
QList<QPersistentModelIndex> rows;
for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) {
rows.append(QPersistentModelIndex(deckListModel->index(i, 0, trackedIndex)));
}
for (const QPersistentModelIndex &persistent : rows) {
constructAppropriateWidget(persistent);
}
}

View file

@ -42,7 +42,6 @@ public:
void addCardsToOverlapWidget();
public slots:
void onClick(QMouseEvent *event, const ExactCard &card);
void onHover(const ExactCard &card);
void cleanupInvalidCardGroup(CardGroupDisplayWidget *displayWidget);
void constructAppropriateWidget(QPersistentModelIndex index);

View file

@ -90,6 +90,13 @@ void CardDatabaseView::decrementCard(const QString &zoneName)
emit cardDecremented(currentCardName(), zoneName);
}
void CardDatabaseView::setZoneMenuProvider(const std::function<QList<QPair<QString, QStringList>>()> &provider,
const std::function<QString()> &newZoneHandler)
{
zoneMenuProvider = provider;
this->newZoneHandler = newZoneHandler;
}
void CardDatabaseView::updateCard(const QModelIndex &current, const QModelIndex & /*previous*/)
{
if (!current.isValid()) {
@ -142,6 +149,50 @@ void CardDatabaseView::openCustomMenu(QPoint point)
[this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); });
connect(selectPrinting, &QAction::triggered, this, &CardDatabaseView::selectPrintingClicked);
if (zoneMenuProvider) {
QMenu *addToZoneMenu = menu.addMenu(tr("Add to Zone"));
const auto zoneBoards = zoneMenuProvider();
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
// Boards with zones nest their children so no two menu entries
// share a visible name: "Maindeck ▸ { Maindeck (whole board), … }".
const QStringList customZones = [&zoneBoards, boardName] {
for (const auto &zoneBoard : zoneBoards) {
if (zoneBoard.first == boardName) {
return zoneBoard.second;
}
}
return QStringList();
}();
if (customZones.isEmpty()) {
QAction *action = addToZoneMenu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
connect(action, &QAction::triggered, this,
[this, card, boardName] { emit cardAdded(card->getName(), boardName); });
} else {
QMenu *boardSubmenu = addToZoneMenu->addMenu(InnerDecklistNode::visibleNameFromName(boardName));
QAction *wholeBoardAction = boardSubmenu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
connect(wholeBoardAction, &QAction::triggered, this,
[this, card, boardName] { emit cardAdded(card->getName(), boardName); });
for (const QString &zoneName : customZones) {
QAction *action = boardSubmenu->addAction(zoneName);
connect(action, &QAction::triggered, this,
[this, card, zoneName] { emit cardAdded(card->getName(), zoneName); });
}
}
}
if (newZoneHandler) {
addToZoneMenu->addSeparator();
QAction *newZoneAction = addToZoneMenu->addAction(tr("Create &new zone..."));
connect(newZoneAction, &QAction::triggered, this, [this, card] {
const QString zoneName = newZoneHandler();
if (!zoneName.isEmpty()) {
emit cardAdded(card->getName(), zoneName);
}
});
}
}
if (canBeCommander(*card)) {
QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)"));
connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); });

View file

@ -4,6 +4,7 @@
#include "../../key_signals.h"
#include <QTreeView>
#include <functional>
#include <libcockatrice/card/card_info.h>
class CardDatabaseModel;
@ -19,6 +20,13 @@ class CardDatabaseView : public QTreeView
KeySignals searchKeySignals;
CardDatabaseDisplayModel *databaseDisplayModel;
/// Provides the custom zones available in the current deck, grouped by board zone.
/// The list contains (board zone name, custom zone names) pairs for every board.
std::function<QList<QPair<QString, QStringList>>()> zoneMenuProvider;
/// Handler invoked when the user picks "New zone..." from the add-to-zone menu.
/// Returns the name of the created zone, or an empty string if creation was cancelled.
std::function<QString()> newZoneHandler;
public:
explicit CardDatabaseView(QWidget *parent, CardDatabaseDisplayModel *model);
@ -33,6 +41,17 @@ public:
return &searchKeySignals;
}
/**
* @brief Sets the provider used to populate the "Add to zone" submenu of the context menu.
* If no provider is set, the submenu is not shown.
*
* @param provider Returns the custom zones of the current deck, grouped by board zone
* @param newZoneHandler Creates a new custom zone and returns its name, or an empty string
* if creation was cancelled. The menu entry is hidden when not provided.
*/
void setZoneMenuProvider(const std::function<QList<QPair<QString, QStringList>>()> &provider,
const std::function<QString()> &newZoneHandler);
signals:
void cardChanged(const QString &cardName);

View file

@ -1,5 +1,12 @@
#include "deck_editor_card_database_dock_widget.h"
#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h"
#include "card_database_view.h"
#include "deck_state_manager.h"
#include "deck_zone_dialog.h"
#include <libcockatrice/deck_list/deck_list_node_tree.h>
DeckEditorCardDatabaseDockWidget::DeckEditorCardDatabaseDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent)
{
setObjectName("databaseDisplayDock");
@ -15,6 +22,27 @@ void DeckEditorCardDatabaseDockWidget::createDatabaseDisplayDock(AbstractTabDeck
{
databaseDisplayWidget = new DeckEditorDatabaseDisplayWidget(this, deckEditor->databaseModel);
databaseDisplayWidget->getDatabaseView()->setZoneMenuProvider(
[deckEditor]() -> QList<QPair<QString, QStringList>> {
QList<QPair<QString, QStringList>> result;
auto *deckListModel = deckEditor->deckStateManager->getModel();
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
result.append({boardName, deckListModel->getCustomZoneNames(boardName)});
}
return result;
},
[this, deckEditor]() -> QString {
QString boardName;
const QString zoneName =
DeckZoneDialog::promptForNewZone(this, {}, &boardName, [deckEditor](const QString &candidate) {
return deckEditor->deckStateManager->validateNewZoneName(candidate);
});
if (!zoneName.isEmpty()) {
deckEditor->deckStateManager->createCustomZone(boardName, zoneName);
}
return zoneName;
});
auto *frame = new QVBoxLayout;
frame->setObjectName("databaseDisplayFrame");
frame->addWidget(databaseDisplayWidget);

View file

@ -7,15 +7,18 @@
#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
#include "deck_list_style_proxy.h"
#include "deck_state_manager.h"
#include "deck_zone_dialog.h"
#include <QComboBox>
#include <QDockWidget>
#include <QHeaderView>
#include <QLabel>
#include <QMessageBox>
#include <QPushButton>
#include <QSplitter>
#include <QTextEdit>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list_node_tree.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/macros.h>
@ -772,14 +775,213 @@ void DeckEditorDeckDockWidget::offsetCountAtIndex(const QModelIndex &idx, bool i
void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point)
{
const QModelIndex sourceIndex = proxy->mapToSource(deckView->indexAt(point));
QMenu menu;
const bool isCustomZoneRow = sourceIndex.isValid() && sourceIndex.data(DeckRoles::IsCustomZoneRole).toBool();
const bool isBoardZoneRow = sourceIndex.isValid() && !isCustomZoneRow && !sourceIndex.parent().isValid();
const bool isCardRow =
sourceIndex.isValid() && !isCustomZoneRow && !isBoardZoneRow && !getModel()->hasChildren(sourceIndex);
// Walk the row up to its top-level node to find the hosting board. Cards in
// the tokens board cannot be moved (moveCardToZone bails for it), so the
// move menu is skipped for them.
QString currentBoardName;
QModelIndex board = sourceIndex.parent();
while (board.isValid() && board.parent().isValid()) {
board = board.parent();
}
if (board.isValid()) {
currentBoardName = board.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
}
if (isCardRow) {
if (currentBoardName != DECK_ZONE_TOKENS) {
addMoveToZoneMenu(&menu, sourceIndex, currentBoardName);
menu.addSeparator();
}
} else if (isCustomZoneRow) {
const QString zoneName =
sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
QAction *renameAction = menu.addAction(tr("&Rename zone..."));
connect(renameAction, &QAction::triggered, this, [this, zoneName] {
// The unchanged name must not validate as a duplicate.
const QString newName =
DeckZoneDialog::promptForRename(this, zoneName, [this, zoneName](const QString &candidate) {
return candidate == zoneName ? QString() : deckStateManager->validateNewZoneName(candidate);
});
if (!newName.isEmpty() && newName != zoneName) {
deckStateManager->renameCustomZone(zoneName, newName);
}
});
QMenu *boardMenu = menu.addMenu(tr("Change &board"));
addChangeBoardMenu(boardMenu, zoneName);
QAction *deleteAction = menu.addAction(tr("&Delete zone"));
const bool zoneHasCards = getModel()->hasChildren(sourceIndex);
deleteAction->setEnabled(!zoneHasCards);
if (zoneHasCards) {
deleteAction->setToolTip(tr("Move or remove all cards first."));
menu.setToolTipsVisible(true);
}
connect(deleteAction, &QAction::triggered, this, [this, zoneName] {
const auto result =
QMessageBox::warning(this, tr("Delete zone"), tr("Delete the zone \"%1\"?").arg(zoneName),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (result == QMessageBox::Yes) {
deckStateManager->removeCustomZone(zoneName);
}
});
menu.addSeparator();
} else if (isBoardZoneRow) {
const QString boardName =
sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
// Tokens cannot host custom zones, so only offer the action on real boards.
const bool canHostCustomZones =
boardName == DECK_ZONE_MAIN || boardName == DECK_ZONE_SIDE || boardName == DECK_ZONE_MAYBEBOARD;
if (canHostCustomZones) {
addNewZoneAction(&menu, boardName);
menu.addSeparator();
}
} else if (!sourceIndex.isValid()) {
addNewZoneAction(&menu);
menu.addSeparator();
}
QAction *selectPrinting = menu.addAction(tr("Select Printing"));
connect(selectPrinting, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::showPrintingSelector);
menu.exec(deckView->mapToGlobal(point));
}
void DeckEditorDeckDockWidget::addMoveToZoneMenu(QMenu *menu,
const QModelIndex &sourceCardIndex,
const QString &currentBoardName)
{
// The card's current *zone*, derived with the same ancestor walk as
// DeckStateManager::moveCardToZone (nearest custom-zone ancestor, else the
// top-level board/zone): a card inside "Removal" under the maindeck lives in
// "Removal", not "main". Comparing against that instead of the board keeps
// the enabled state and the same-zone no-op consistent with the move logic.
QString currentZoneName;
for (QModelIndex ancestor = sourceCardIndex.parent(); ancestor.isValid(); ancestor = ancestor.parent()) {
if (ancestor.data(DeckRoles::IsCustomZoneRole).toBool() || !ancestor.parent().isValid()) {
currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
break;
}
}
const auto addMoveAction = [this, sourceCardIndex](QMenu *targetMenu, const QString &targetZoneName,
const QString &label, bool enabled) {
QAction *action = targetMenu->addAction(label);
action->setEnabled(enabled);
if (enabled) {
connect(action, &QAction::triggered, this, [this, sourceCardIndex, targetZoneName] {
deckStateManager->moveCardToZone(sourceCardIndex, targetZoneName);
});
}
};
const auto tree = deckStateManager->getDeckListShared()->getTree();
QMenu *moveMenu = menu->addMenu(tr("Move to &zone"));
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
const QString boardLabel = InnerDecklistNode::visibleNameFromName(boardName);
const auto customZones = tree->getCustomZones(boardName);
// Boards with zones nest their children so no two menu entries share a
// visible name: "Maindeck ▸ { Maindeck (whole board), Removal, … }".
// The board the card already lives on is marked instead of offered.
if (!customZones.isEmpty()) {
QMenu *boardSubmenu = moveMenu->addMenu(boardLabel);
addMoveAction(boardSubmenu, boardName, boardLabel, boardName != currentZoneName);
for (const auto *customZone : customZones) {
addMoveAction(boardSubmenu, customZone->getName(), customZone->getName(),
customZone->getName() != currentZoneName);
}
} else {
addMoveAction(moveMenu, boardName, boardLabel, boardName != currentZoneName);
}
}
moveMenu->addSeparator();
QAction *newZoneAction = moveMenu->addAction(tr("Create new zone and move &here..."));
connect(newZoneAction, &QAction::triggered, this, [this, sourceCardIndex, currentBoardName, currentZoneName] {
// Resolve the card's identity before creating the zone:
// createNewCustomZone rebuilds the model tree, so sourceCardIndex's
// internal pointer is freed by the time it would be used.
const QString cardName =
sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
const QString providerId =
sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString();
const QString collectorNumber = sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_COLLECTOR_NUMBER)
.data(Qt::DisplayRole)
.toString();
const QString zoneName = createNewCustomZone(currentBoardName);
if (!zoneName.isEmpty()) {
// Re-find the card: the old index is no longer safe since rows were
// rebuilt. Mirror DeckStateManager::decrementCard's re-find pattern.
const QModelIndex refreshed = getModel()->findCard(cardName, currentZoneName, providerId, collectorNumber);
if (refreshed.isValid()) {
deckStateManager->moveCardToZone(refreshed, zoneName);
}
}
});
}
void DeckEditorDeckDockWidget::addChangeBoardMenu(QMenu *menu, const QString &zoneName)
{
const auto tree = deckStateManager->getDeckListShared()->getTree();
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
QAction *action = menu->addAction(InnerDecklistNode::visibleNameFromName(boardName));
// The board currently holding the zone is marked instead of offered.
// Duplicate names cannot come up through the editor, so this doubles as
// the uniqueness guard for imported decks.
bool holdsTheZone = false;
for (const auto *customZone : tree->getCustomZones(boardName)) {
if (customZone->getName() == zoneName) {
holdsTheZone = true;
break;
}
}
if (holdsTheZone) {
action->setCheckable(true);
action->setChecked(true);
continue;
}
connect(action, &QAction::triggered, this,
[this, zoneName, boardName] { deckStateManager->moveCustomZone(zoneName, boardName); });
}
}
void DeckEditorDeckDockWidget::addNewZoneAction(QMenu *menu, const QString &initialBoardName)
{
QAction *newZoneAction = menu->addAction(tr("Create &new zone..."));
connect(newZoneAction, &QAction::triggered, this,
[this, initialBoardName] { createNewCustomZone(initialBoardName); });
}
QString DeckEditorDeckDockWidget::createNewCustomZone(const QString &initialBoardName)
{
QString boardName;
const QString zoneName =
DeckZoneDialog::promptForNewZone(this, initialBoardName, &boardName, [this](const QString &candidate) {
return deckStateManager->validateNewZoneName(candidate);
});
if (!zoneName.isEmpty()) {
deckStateManager->createCustomZone(boardName, zoneName);
}
return zoneName;
}
void DeckEditorDeckDockWidget::refreshShortcuts()
{
ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts();

View file

@ -15,8 +15,12 @@
#include "deck_list_history_manager_widget.h"
#include "deck_list_style_proxy.h"
#include <QCheckBox>
#include <QComboBox>
#include <QDockWidget>
#include <QLabel>
#include <QMenu>
#include <QPushButton>
#include <QTextEdit>
#include <QTreeView>
#include <libcockatrice/card/card_info.h>
@ -99,6 +103,11 @@ private:
[[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const;
void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement);
void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString &currentBoardName);
void addChangeBoardMenu(QMenu *menu, const QString &zoneName);
QString createNewCustomZone(const QString &initialBoardName = {});
void addNewZoneAction(QMenu *menu, const QString &initialBoardName = {});
private slots:
void decklistCustomMenu(QPoint point);
void updateCard(QModelIndex, const QModelIndex &current);

View file

@ -2,6 +2,7 @@
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list_history_manager.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
DeckStateManager::DeckStateManager(QObject *parent)
: QObject(parent), deckList(QSharedPointer<DeckList>(new DeckList)),
@ -307,6 +308,170 @@ bool DeckStateManager::decrementCountAtIndex(const QModelIndex &idx)
return offsetCountAtIndex(idx, -1);
}
bool DeckStateManager::moveCardToZone(const QModelIndex &idx, const QString &targetZoneName)
{
if (!idx.isValid()) {
return false;
}
// Only actual card rows can be moved. Group or zone rows report an
// aggregate amount and must never be deleted by this operation.
if (!idx.data(DeckRoles::IsCardRole).toBool()) {
return false;
}
QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString();
int copies = idx.siblingAtColumn(DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
if (copies <= 0) {
return false;
}
// Tokens only live in the tokens zone and cannot be moved into decks.
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName);
if (info && info->getIsToken()) {
return false;
}
// Determine the zone the card currently lives in: the enclosing custom
// zone, or the nearest top-level zone (board zone or legacy zone).
QString currentZoneName;
for (QModelIndex ancestor = idx.parent(); ancestor.isValid(); ancestor = ancestor.parent()) {
bool isCustomZone = ancestor.data(DeckRoles::IsCustomZoneRole).toBool();
if (isCustomZone || !ancestor.parent().isValid()) {
currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
break;
}
}
if (currentZoneName == targetZoneName) {
return false;
}
QString reason = tr("Moved %1 × \"%2\" (%3) to %4")
.arg(copies)
.arg(cardName)
.arg(providerId)
.arg(InnerDecklistNode::visibleNameFromName(targetZoneName));
return modifyDeck(reason, [&idx, &cardName, &providerId, &targetZoneName, copies](auto model) {
if (!model->removeRow(idx.row(), idx.parent())) {
return false;
}
if (ExactCard card = CardDatabaseManager::query()->getCard({cardName, providerId})) {
for (int i = 0; i < copies; ++i) {
model->addCard(card, targetZoneName);
}
} else {
for (int i = 0; i < copies; ++i) {
model->addPreferredPrintingCard(cardName, targetZoneName, true);
}
}
return true;
});
}
bool DeckStateManager::createCustomZone(const QString &boardZoneName, const QString &zoneName)
{
const QString trimmedZoneName = zoneName.trimmed();
if (trimmedZoneName.isEmpty()) {
return false;
}
QString reason =
tr("Created zone \"%1\" in %2").arg(trimmedZoneName, InnerDecklistNode::visibleNameFromName(boardZoneName));
return modifyTree(reason, [&boardZoneName, &trimmedZoneName](DecklistNodeTree *tree) {
return tree->addCustomZone(boardZoneName, trimmedZoneName) != nullptr;
});
}
bool DeckStateManager::renameCustomZone(const QString &oldZoneName, const QString &newZoneName)
{
const QString trimmedNewZoneName = newZoneName.trimmed();
if (trimmedNewZoneName.isEmpty() || oldZoneName == trimmedNewZoneName) {
return false;
}
QString reason = tr("Renamed zone \"%1\" to \"%2\"").arg(oldZoneName, trimmedNewZoneName);
return modifyTree(reason, [&oldZoneName, &trimmedNewZoneName](DecklistNodeTree *tree) {
return tree->renameCustomZone(oldZoneName, trimmedNewZoneName);
});
}
bool DeckStateManager::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName)
{
const auto *tree = deckList->getTree();
// Locate the zone through the tree's own lookup, which walks every top-level
// zone (not just the standard boards) and covers the same-board no-op below.
const auto *zone = tree->findCustomZoneByName(zoneName);
if (!zone) {
return false;
}
// Same-board moves are no-ops and must not pollute the history.
const QString currentBoardName = zone->getParent() ? zone->getParent()->getName() : QString();
if (currentBoardName == newBoardZoneName) {
return true;
}
// Zone names are deck-unique among zones created through this manager, so a
// same-named zone on the target board can only come from an imported deck.
// Refuse the move instead of silently stacking same-named zones.
for (const auto *targetZone : tree->getCustomZones(newBoardZoneName)) {
if (targetZone->getName() == zoneName) {
return false;
}
}
QString reason =
tr("Moved zone \"%1\" to %2").arg(zoneName, InnerDecklistNode::visibleNameFromName(newBoardZoneName));
return modifyTree(reason, [&zoneName, &newBoardZoneName](DecklistNodeTree *tree) {
return tree->moveCustomZone(zoneName, newBoardZoneName);
});
}
bool DeckStateManager::removeCustomZone(const QString &zoneName)
{
QString reason = tr("Deleted zone \"%1\"").arg(zoneName);
return modifyTree(reason, [&zoneName](DecklistNodeTree *tree) { return tree->removeCustomZone(zoneName); });
}
QString DeckStateManager::validateNewZoneName(const QString &zoneName) const
{
if (zoneName.trimmed().isEmpty()) {
return tr("Enter a zone name.");
}
const QString trimmedZoneName = zoneName.trimmed();
// The standard zone names are reserved even before they exist.
if (trimmedZoneName == DECK_ZONE_MAIN || trimmedZoneName == DECK_ZONE_SIDE ||
trimmedZoneName == DECK_ZONE_MAYBEBOARD || trimmedZoneName == DECK_ZONE_TOKENS) {
return tr("This name is reserved.");
}
const auto *tree = deckList->getTree();
// Reuse the tree's own uniqueness contract: any top-level zone and any
// custom zone on *every* board claims the name (hasZoneName also reserves
// the standard board names, which we already rejected with a dedicated
// message above). Scanning only the standard boards here would miss a
// custom zone an imported deck carries under `tokens`.
if (tree->hasZoneName(trimmedZoneName)) {
return tr("A zone with this name already exists.");
}
return {};
}
bool DeckStateManager::offsetCountAtIndex(const QModelIndex &idx, int offset)
{
if (!idx.isValid()) {
@ -367,6 +532,25 @@ void DeckStateManager::requestHistorySave(const QString &reason)
historyManager->save(deckList->createMemento(reason));
}
bool DeckStateManager::modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation)
{
DeckListMemento memento = deckList->createMemento(reason);
bool success = operation(deckList->getTree());
if (success) {
historyManager->save(memento);
deckListModel->rebuildTree();
deckList->refreshDeckHash();
emit deckListModel->deckHashChanged();
// removeCustomZone can drop whole card sets the model never notified
// about (rebuildTree emits no cardNodesChanged), so tell the consumers.
emit deckListModel->cardNodesChanged();
doCardModified();
}
return success;
}
/**
* @brief Handles updating state and emitting signals whenever the cards are modified
*/

View file

@ -5,6 +5,7 @@
#include "deck_list_model.h"
#include <QSharedPointer>
#include <functional>
#include <libcockatrice/deck_list/deck_list.h>
class DeckListHistoryManager;
@ -236,6 +237,68 @@ public:
*/
bool decrementCountAtIndex(const QModelIndex &idx);
/**
* @brief Moves all copies of the card at the given index to the given zone.
* No-ops if the index is invalid, not a card node, the card is a token, or the
* card is already in the target zone.
* Saves the operation to history if successful.
*
* @param idx The model index of the card to move
* @param targetZoneName The zone to move the card to (board zone or custom zone name)
* @return Whether the operation was successfully performed
*/
bool moveCardToZone(const QModelIndex &idx, const QString &targetZoneName);
/**
* @brief Creates a new custom zone nested under a board zone.
* Saves the operation to history if successful.
*
* @param boardZoneName The board zone to nest the custom zone under
* @param zoneName The name of the new custom zone. Gets trimmed and must be
* unique across the deck.
* @return Whether the zone was created
*/
bool createCustomZone(const QString &boardZoneName, const QString &zoneName);
/**
* @brief Renames a custom zone.
* Saves the operation to history if successful.
*
* @param oldZoneName The current name of the custom zone
* @param newZoneName The new name. Gets trimmed and must be unique across the deck.
* @return Whether the rename succeeded
*/
bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName);
/**
* @brief Moves a custom zone (and its cards) to a different board zone.
* Same-board moves succeed without creating a history entry.
* Saves the operation to history if successful.
*
* @param zoneName The custom zone to move
* @param newBoardZoneName The board zone to move the custom zone under
* @return Whether the move succeeded
*/
bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName);
/**
* @brief Removes a custom zone and all its cards.
* Saves the operation to history if successful.
*
* @param zoneName The custom zone to remove
* @return Whether the zone was removed
*/
bool removeCustomZone(const QString &zoneName);
/**
* @brief Checks whether a candidate name is usable for a new custom zone.
*
* @param zoneName The candidate name
* @return An empty string when the name is usable, otherwise a user-facing
* error message describing the problem
*/
[[nodiscard]] QString validateNewZoneName(const QString &zoneName) const;
/**
* Undoes n steps of the history, setting the decklist state and updating the current step in the historyManager.
* @param steps Number of steps to undo.
@ -257,6 +320,7 @@ public slots:
private:
bool offsetCountAtIndex(const QModelIndex &idx, int offset);
bool modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation);
void doCardModified();
void doMetadataModified();

View file

@ -0,0 +1,145 @@
#include "deck_zone_dialog.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QVBoxLayout>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <libcockatrice/utility/string_limits.h>
DeckZoneDialog::DeckZoneDialog(QWidget *parent,
const QString &initialBoardName,
const std::function<QString(const QString &)> &_nameValidator,
bool _allowBoardSelection)
: QDialog(parent), nameValidator(_nameValidator), allowBoardSelection(_allowBoardSelection)
{
nameLabel = new QLabel(this);
nameEdit = new QLineEdit(this);
nameEdit->setMaxLength(MAX_NAME_LENGTH);
errorLabel = new QLabel(this);
errorLabel->hide();
boardLabel = new QLabel(this);
boardCombo = new QComboBox(this);
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
// Use the icon overload explicitly so `boardName` lands in the user data role
// (visible text is applied below in retranslateUi). The two-argument form
// addItem({}, boardName) would be ambiguous and resolve to the icon overload
// with empty user data, yielding empty entries and an empty getBoardName().
boardCombo->addItem({}, {}, boardName);
}
if (!initialBoardName.isEmpty()) {
int idx = boardCombo->findData(initialBoardName);
if (idx != -1) {
boardCombo->setCurrentIndex(idx);
}
}
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false);
connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this);
layout->addWidget(nameLabel);
layout->addWidget(nameEdit);
layout->addWidget(errorLabel);
if (allowBoardSelection) {
layout->addWidget(boardLabel);
layout->addWidget(boardCombo);
} else {
boardLabel->hide();
boardCombo->hide();
}
layout->addWidget(buttonBox);
retranslateUi();
connect(nameEdit, &QLineEdit::textChanged, this, [this] { validateName(); });
validateName();
nameEdit->setFocus();
}
QString DeckZoneDialog::getZoneName() const
{
return nameEdit->text().trimmed();
}
QString DeckZoneDialog::getBoardName() const
{
return boardCombo->currentData().toString();
}
void DeckZoneDialog::setZoneName(const QString &zoneName)
{
nameEdit->setText(zoneName);
nameEdit->selectAll();
}
void DeckZoneDialog::changeEvent(QEvent *event)
{
QDialog::changeEvent(event);
if (event->type() == QEvent::LanguageChange) {
retranslateUi();
}
}
void DeckZoneDialog::retranslateUi()
{
setWindowTitle(allowBoardSelection ? tr("New zone") : tr("Rename zone"));
nameLabel->setText(tr("Zone &name:"));
nameLabel->setBuddy(nameEdit);
boardLabel->setText(tr("&Parent zone:"));
boardLabel->setBuddy(boardCombo);
for (int i = 0; i < boardCombo->count(); i++) {
boardCombo->setItemText(i, InnerDecklistNode::visibleNameFromName(boardCombo->itemData(i).toString()));
}
}
void DeckZoneDialog::validateName()
{
const QString zoneName = nameEdit->text().trimmed();
QString error;
if (zoneName.isEmpty()) {
error = tr("Enter a zone name.");
} else if (nameValidator) {
error = nameValidator(zoneName);
}
errorLabel->setText(error);
errorLabel->setVisible(!error.isEmpty());
buttonBox->button(QDialogButtonBox::Ok)->setEnabled(error.isEmpty());
}
QString DeckZoneDialog::promptForNewZone(QWidget *parent,
const QString &initialBoardName,
QString *chosenBoardName,
const std::function<QString(const QString &)> &nameValidator)
{
DeckZoneDialog dialog(parent, initialBoardName, nameValidator);
if (dialog.exec() != QDialog::Accepted) {
return {};
}
if (chosenBoardName) {
*chosenBoardName = dialog.getBoardName();
}
return dialog.getZoneName();
}
QString DeckZoneDialog::promptForRename(QWidget *parent,
const QString &currentZoneName,
const std::function<QString(const QString &)> &nameValidator)
{
DeckZoneDialog dialog(parent, {}, nameValidator, false);
dialog.setZoneName(currentZoneName);
return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString();
}

View file

@ -0,0 +1,123 @@
/**
* @file deck_zone_dialog.h
* @ingroup DeckEditorWidgets
* @brief Shared dialog for creating custom deck zones.
*/
#ifndef DECK_ZONE_DIALOG_H
#define DECK_ZONE_DIALOG_H
#include <QDialog>
#include <QEvent>
#include <QString>
#include <functional>
class QComboBox;
class QDialogButtonBox;
class QLabel;
class QLineEdit;
class QWidget;
/**
* @brief Modal dialog asking for the name and parent zone of a new custom deck zone.
*
* Menus construct the dialog transiently around exec(), so validation state only
* ever reflects the name currently typed.
*/
class DeckZoneDialog : public QDialog
{
Q_OBJECT
public:
/**
* @brief Constructs the dialog and runs the initial validation pass.
*
* @param parent The parent widget for the dialog
* @param initialBoardName The board zone to preselect in the combo. Unknown names
* fall back to main.
* @param _nameValidator Given the trimmed candidate name, returns an empty string
* when it is usable, otherwise a user-facing error message. May be empty.
* @param _allowBoardSelection When false the parent-zone combo is hidden and the
* dialog acts as a rename prompt for an existing zone.
*/
explicit DeckZoneDialog(QWidget *parent = nullptr,
const QString &initialBoardName = {},
const std::function<QString(const QString &)> &_nameValidator = {},
bool _allowBoardSelection = true);
/**
* @brief The trimmed zone name entered by the user.
*/
[[nodiscard]] QString getZoneName() const;
/**
* @brief The internal name of the board zone selected in the combo.
*/
[[nodiscard]] QString getBoardName() const;
/**
* @brief Prefills the name field, e.g. with the current name when renaming.
*
* @param zoneName The text to put into the name field, selected for quick editing
*/
void setZoneName(const QString &zoneName);
/**
* @brief Prompts the user for a new custom zone name and the board zone to nest it under.
*
* Convenience wrapper that runs DeckZoneDialog modally.
*
* @param parent The parent widget for the dialog
* @param initialBoardName The board zone to preselect in the dialog. Unknown names fall
* back to main.
* @param chosenBoardName (out) The internal name of the board zone the user chose
* @param nameValidator Optional validator forwarded to the dialog
* @return The trimmed zone name, or an empty string if the user cancelled
*/
static QString promptForNewZone(QWidget *parent,
const QString &initialBoardName,
QString *chosenBoardName,
const std::function<QString(const QString &)> &nameValidator = {});
/**
* @brief Prompts the user for a new name for an existing custom zone.
*
* Same inline validation as promptForNewZone, but without a parent-zone picker.
*
* @param parent The parent widget for the dialog
* @param currentZoneName The current name, prefilled for editing
* @param nameValidator Validator deciding whether a candidate name is usable. It sees
* the current name too, so callers wanting to allow unchanged names must
* special-case that themselves.
* @return The trimmed new name, or an empty string if the user cancelled
*/
static QString promptForRename(QWidget *parent,
const QString &currentZoneName,
const std::function<QString(const QString &)> &nameValidator = {});
protected:
void changeEvent(QEvent *event) override;
private:
/**
* @brief Sets every user-visible string. Runs on construction and on runtime
* language changes.
*/
void retranslateUi();
/**
* @brief Validates the current input, toggling Ok and the inline error label.
*/
void validateName();
QLabel *nameLabel;
QLineEdit *nameEdit;
QLabel *errorLabel;
QLabel *boardLabel;
QComboBox *boardCombo;
QDialogButtonBox *buttonBox;
std::function<QString(const QString &)> nameValidator;
bool allowBoardSelection;
};
#endif // DECK_ZONE_DIALOG_H

View file

@ -1,9 +1,17 @@
#include "dlg_convert_deck_to_cod_format.h"
#include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h"
#include <QCheckBox>
#include <QDialogButtonBox>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QLabel>
#include <QMessageBox>
#include <QVBoxLayout>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
DialogConvertDeckToCodFormat::DialogConvertDeckToCodFormat(QWidget *parent) : QDialog(parent)
{
@ -38,3 +46,71 @@ bool DialogConvertDeckToCodFormat::dontAskAgain() const
{
return dontAskAgainCheckbox->isChecked();
}
namespace
{
bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath)
{
QFileInfo fileInfo(filePath);
QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod");
if (QFile::exists(newFileName)) {
QMessageBox::StandardButton reply =
QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"),
QObject::tr("A .cod version of this deck already exists. Overwrite it?"),
QMessageBox::Yes | QMessageBox::No);
return reply == QMessageBox::Yes;
}
return true; // Safe to proceed
}
} // namespace
bool DialogConvertDeckToCodFormat::promptIfRequired(QWidget *parent,
const QString &filePath,
const std::function<bool()> &convert)
{
if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) {
return true;
}
// Retrieve saved preference if the prompt is disabled
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) {
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) {
return false;
}
if (!confirmOverwriteIfExists(parent, filePath)) {
return false;
}
return convert();
}
// Show the dialog to the user
DialogConvertDeckToCodFormat conversionDialog(parent);
if (conversionDialog.exec() != QDialog::Accepted) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(
!conversionDialog.dontAskAgain());
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false);
return false;
}
// Try to convert file
if (!confirmOverwriteIfExists(parent, filePath)) {
return false;
}
if (!convert()) {
return false;
}
if (conversionDialog.dontAskAgain()) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false);
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true);
}
return true;
}

View file

@ -13,6 +13,9 @@
#include <QDialogButtonBox>
#include <QLabel>
#include <QVBoxLayout>
#include <functional>
class QWidget;
class DialogConvertDeckToCodFormat : public QDialog
{
@ -24,6 +27,21 @@ public:
[[nodiscard]] bool dontAskAgain() const;
/**
* @brief Checks whether the deck file at \a filePath can store tags.
*
* If the file is not a .cod deck, prompts the user for conversion to the
* Cockatrice format, honoring the saved "always convert / don't ask again"
* preference. On acceptance \a convert is called to perform the conversion.
*
* @param parent The widget to parent the prompt to.
* @param filePath The path of the deck file to check.
* @param convert Called to convert the deck once the user agrees.
* @return true if tags can be stored (no conversion needed, or the conversion
* was performed), false if the user declined to convert.
*/
static bool promptIfRequired(QWidget *parent, const QString &filePath, const std::function<bool()> &convert);
private:
QVBoxLayout *layout;
QLabel *label;

View file

@ -1,18 +1,62 @@
#include "dlg_register.h"
#include "../../../client/settings/cache_settings.h"
#include "../server/handle_public_servers.h"
#include "../server/user/user_info_connection.h"
#include <QCheckBox>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPushButton>
#include <QRadioButton>
#include <QVBoxLayout>
#include <libcockatrice/settings/servers_settings.h>
#include <libcockatrice/utility/string_limits.h>
DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
{
// ── Server picker ──────────────────────────────────────────────────
previousHostButton = new QRadioButton(tr("Known Hosts"), this);
previousHosts = new QComboBox(this);
btnDeleteServer = new QPushButton(this);
btnDeleteServer->setIcon(QPixmap("theme:icons/remove_row"));
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
btnDeleteServer->setFixedWidth(30);
connect(btnDeleteServer, &QPushButton::clicked, this, &DlgRegister::actRemoveSavedServer);
hps = new HandlePublicServers(this);
btnRefreshServers = new QPushButton(this);
btnRefreshServers->setIcon(QPixmap("theme:icons/sync"));
btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers"));
btnRefreshServers->setFixedWidth(30);
connect(hps, &HandlePublicServers::sigPublicServersDownloadedSuccessfully, this, [this] { rebuildComboBoxList(); });
connect(hps, &HandlePublicServers::sigPublicServersDownloadedUnsuccessfully, this,
&DlgRegister::rebuildComboBoxList);
connect(btnRefreshServers, &QPushButton::released, this, &DlgRegister::downloadThePublicServers);
newHostButton = new QRadioButton(tr("New Host"), this);
auto *serverPickerRow = new QHBoxLayout;
serverPickerRow->addWidget(previousHosts);
serverPickerRow->addWidget(btnDeleteServer);
serverPickerRow->addWidget(btnRefreshServers);
auto *serverGroupLayout = new QVBoxLayout;
serverGroupLayout->addWidget(previousHostButton);
serverGroupLayout->addLayout(serverPickerRow);
serverGroupLayout->addWidget(newHostButton);
auto *serverGroupBox = new QGroupBox(tr("Server"));
serverGroupBox->setLayout(serverGroupLayout);
// ── Registration fields ────────────────────────────────────────────
ServersSettings &servers = SettingsCache::instance().servers();
infoLabel = new QLabel(tr("Enter your information and the information of the server you'd like to register to.\n"
"Your email will be used to verify your account."));
@ -321,26 +365,28 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
realnameEdit->setMaxLength(MAX_NAME_LENGTH);
realnameLabel->setBuddy(realnameEdit);
// ── Layout ─────────────────────────────────────────────────────────
auto *grid = new QGridLayout;
grid->addWidget(infoLabel, 0, 0, 1, 2);
grid->addWidget(hostLabel, 1, 0);
grid->addWidget(hostEdit, 1, 1);
grid->addWidget(portLabel, 2, 0);
grid->addWidget(portEdit, 2, 1);
grid->addWidget(playernameLabel, 3, 0);
grid->addWidget(playernameEdit, 3, 1);
grid->addWidget(passwordLabel, 4, 0);
grid->addWidget(passwordEdit, 4, 1);
grid->addWidget(passwordConfirmationLabel, 5, 0);
grid->addWidget(passwordConfirmationEdit, 5, 1);
grid->addWidget(emailLabel, 6, 0);
grid->addWidget(emailEdit, 6, 1);
grid->addWidget(emailConfirmationLabel, 7, 0);
grid->addWidget(emailConfirmationEdit, 7, 1);
grid->addWidget(countryLabel, 9, 0);
grid->addWidget(countryEdit, 9, 1);
grid->addWidget(realnameLabel, 10, 0);
grid->addWidget(realnameEdit, 10, 1);
grid->addWidget(serverGroupBox, 0, 0, 1, 2);
grid->addWidget(infoLabel, 1, 0, 1, 2);
grid->addWidget(hostLabel, 2, 0);
grid->addWidget(hostEdit, 2, 1);
grid->addWidget(portLabel, 3, 0);
grid->addWidget(portEdit, 3, 1);
grid->addWidget(playernameLabel, 4, 0);
grid->addWidget(playernameEdit, 4, 1);
grid->addWidget(passwordLabel, 5, 0);
grid->addWidget(passwordEdit, 5, 1);
grid->addWidget(passwordConfirmationLabel, 6, 0);
grid->addWidget(passwordConfirmationEdit, 6, 1);
grid->addWidget(emailLabel, 7, 0);
grid->addWidget(emailEdit, 7, 1);
grid->addWidget(emailConfirmationLabel, 8, 0);
grid->addWidget(emailConfirmationEdit, 8, 1);
grid->addWidget(countryLabel, 10, 0);
grid->addWidget(countryEdit, 10, 1);
grid->addWidget(realnameLabel, 11, 0);
grid->addWidget(realnameEdit, 11, 1);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgRegister::actOk);
@ -352,13 +398,115 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
setLayout(mainLayout);
setWindowTitle(tr("Register to server"));
setFixedHeight(sizeHint().height());
setMinimumWidth(300);
setMinimumWidth(360);
connect(previousHostButton, &QRadioButton::toggled, this, &DlgRegister::previousHostSelected);
connect(newHostButton, &QRadioButton::toggled, this, &DlgRegister::newHostSelected);
connect(previousHosts, &QComboBox::currentTextChanged, this, &DlgRegister::updateDisplayInfo);
previousHostButton->setChecked(true);
preRebuildComboBoxList();
}
DlgRegister::~DlgRegister() = default;
void DlgRegister::downloadThePublicServers()
{
btnRefreshServers->setDisabled(true);
previousHosts->clear();
previousHosts->addItem(placeHolderText);
hps->downloadPublicServers();
}
void DlgRegister::preRebuildComboBoxList()
{
UserConnection_Information uci;
savedHostList = uci.getServerInfo();
if (savedHostList.size() == 1) {
downloadThePublicServers();
} else {
rebuildComboBoxList();
}
}
void DlgRegister::rebuildComboBoxList(int failure)
{
Q_UNUSED(failure);
previousHosts->clear();
UserConnection_Information uci;
savedHostList = uci.getServerInfo();
auto &servers = SettingsCache::instance().servers();
QString previousHostName = servers.getPrevioushostName();
for (const auto &pair : savedHostList) {
const auto &tmp = pair.second;
QString saveName = tmp.getSaveName();
if (saveName.size()) {
previousHosts->addItem(saveName);
if (saveName.compare(previousHostName) == 0) {
previousHosts->setCurrentIndex(previousHosts->count() - 1);
}
}
}
btnRefreshServers->setDisabled(false);
}
void DlgRegister::previousHostSelected(bool state)
{
if (state) {
previousHosts->setDisabled(false);
btnRefreshServers->setDisabled(false);
hostEdit->setDisabled(true);
portEdit->setDisabled(true);
}
}
void DlgRegister::newHostSelected(bool state)
{
if (state) {
previousHosts->setDisabled(true);
btnRefreshServers->setDisabled(true);
hostEdit->setDisabled(false);
hostEdit->clear();
hostEdit->setPlaceholderText(tr("Server URL"));
portEdit->setDisabled(false);
portEdit->clear();
portEdit->setPlaceholderText(tr("Communication Port"));
playernameEdit->setDisabled(false);
playernameEdit->clear();
} else {
// Rebuild the list so the previously selected host's details are
// repopulated (mirrors DlgConnect::newHostSelected).
preRebuildComboBoxList();
}
}
void DlgRegister::updateDisplayInfo(const QString &saveName)
{
if (saveName.isEmpty() || saveName == placeHolderText) {
return;
}
UserConnection_Information uci;
QStringList _data = uci.getServerInfo(saveName);
if (_data.size() < 7) {
return;
}
hostEdit->setText(_data.at(1));
portEdit->setText(_data.at(2));
playernameEdit->setText(_data.at(3));
}
void DlgRegister::actOk()
{
//! \todo This stuff should be using QValidators.
if (passwordEdit->text().length() < 8) {
QMessageBox::critical(this, tr("Registration Warning"), tr("Your password is too short."));
return;
@ -375,5 +523,29 @@ void DlgRegister::actOk()
return;
}
ServersSettings &servers = SettingsCache::instance().servers();
if (newHostButton->isChecked()) {
// Persist the new host so it shows up in the Connect dialog later.
// The password is never stored: the account is not verified yet.
const QString host = hostEdit->text().trimmed();
if (!host.isEmpty()) {
servers.addNewServer(host, host, portEdit->text().trimmed(), playernameEdit->text().trimmed(), QString(),
false);
servers.setPrevioushostName(host);
}
} else {
const QString saveName = previousHosts->currentText();
if (!saveName.isEmpty() && saveName != placeHolderText) {
servers.setPrevioushostName(saveName);
}
}
accept();
}
void DlgRegister::actRemoveSavedServer()
{
SettingsCache::instance().servers().removeServer(hostEdit->text());
previousHosts->removeItem(previousHosts->currentIndex());
}

View file

@ -1,25 +1,24 @@
/**
* @file dlg_register.h
* @ingroup AccountDialogs
*/
//! \todo Document this file.
#ifndef DLG_REGISTER_H
#define DLG_REGISTER_H
#include <QComboBox>
#include <QDialog>
#include <QLineEdit>
#include <QMap>
class HandlePublicServers;
class QLabel;
class QPushButton;
class QCheckBox;
class QRadioButton;
class UserConnection_Information;
class DlgRegister : public QDialog
{
Q_OBJECT
public:
explicit DlgRegister(QWidget *parent = nullptr);
~DlgRegister() override;
[[nodiscard]] QString getHost() const
{
return hostEdit->text();
@ -48,15 +47,35 @@ public:
{
return realnameEdit->text();
}
public slots:
void downloadThePublicServers();
private slots:
void actOk();
void previousHostSelected(bool state);
void newHostSelected(bool state);
void updateDisplayInfo(const QString &saveName);
void preRebuildComboBoxList();
void rebuildComboBoxList(int failure = -1);
void actRemoveSavedServer();
private:
QRadioButton *newHostButton;
QRadioButton *previousHostButton;
QComboBox *previousHosts;
QPushButton *btnDeleteServer;
QPushButton *btnRefreshServers;
HandlePublicServers *hps;
QLabel *infoLabel, *hostLabel, *portLabel, *playernameLabel, *passwordLabel, *passwordConfirmationLabel,
*emailLabel, *emailConfirmationLabel, *countryLabel, *realnameLabel;
QLineEdit *hostEdit, *portEdit, *playernameEdit, *passwordEdit, *passwordConfirmationEdit, *emailEdit,
*emailConfirmationEdit, *realnameEdit;
QComboBox *countryEdit;
QMap<QString, std::pair<QString, UserConnection_Information>> savedHostList;
const QString placeHolderText = tr("Downloading...");
};
#endif
#endif // DLG_REGISTER_H

View file

@ -32,6 +32,7 @@ BannerWidget::BannerWidget(QWidget *parent, const QString &text, Qt::Orientation
// Set minimum height for the widget
setMinimumHeight(50);
setMaximumHeight(100);
connect(this, &BannerWidget::buddyVisibilityChanged, this, &BannerWidget::toggleBuddyVisibility);
updateDropdownIconState();

View file

@ -0,0 +1,49 @@
#ifndef COCKATRICE_HOME_TAB_BUTTON_COLOR_H
#define COCKATRICE_HOME_TAB_BUTTON_COLOR_H
#include <QList>
namespace HomeTabButtonColor
{
/**
* @brief Where to get the colors for the home tab buttons from
*/
enum Source
{
Automatic, ///< Extract color from background, or use theme color if no background
FromBackground, ///< Always extract color from background
};
struct Entry
{
Source source;
const char *trKey; ///< key for translation
};
inline QList<Entry> all()
{
static QList<Entry> entries = {{Automatic, QT_TR_NOOP("Automatic")},
{FromBackground, QT_TR_NOOP("Extract from background")}};
return entries;
}
/**
* Safely converts an int into the corresponding Source.
*
* @param value The int value
* @return The Source. Returns Source::Automatic if the value is not within range
*/
inline Source intToSource(int value)
{
if (value > FromBackground) {
return Automatic; // default
}
return static_cast<Source>(value);
}
} // namespace HomeTabButtonColor
#endif // COCKATRICE_HOME_TAB_BUTTON_COLOR_H

View file

@ -7,6 +7,7 @@
#include "../cards/art_crop_attribution.h"
#include "background_sources.h"
#include "home_styled_button.h"
#include "home_tab_button_color.h"
#include <QGroupBox>
#include <QPainter>
@ -25,7 +26,7 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
backgroundSourceCard = new CardInfoPictureArtCropWidget(this);
gradientColors = extractDominantColors(background);
gradientColors = determineButtonColor();
layout->addWidget(createButtons(), 1, 1, Qt::AlignVCenter | Qt::AlignHCenter);
@ -55,6 +56,8 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
&HomeWidget::initializeBackgroundFromSource);
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this,
&HomeWidget::updateButtonsToBackgroundColor);
connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this,
&HomeWidget::updateButtonsToBackgroundColor);
}
void HomeWidget::initializeBackgroundFromSource()
@ -97,6 +100,34 @@ void HomeWidget::loadBackgroundSourceDeck()
backgroundSourceDeck = deckOpt.has_value() ? deckOpt.value().deckList : DeckList();
}
static bool isDefaultBackgroundAndTheme()
{
QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource();
return themeManager->isBuiltInTheme() && BackgroundSources::fromId(sourceId) == BackgroundSources::Theme;
}
QPair<QColor, QColor> HomeWidget::determineButtonColor() const
{
static QPair defaultColor = {QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80)};
auto colorSource =
HomeTabButtonColor::intToSource(SettingsCache::instance().appearance().getHomeTabButtonColorSourceIndex());
switch (colorSource) {
case HomeTabButtonColor::Automatic: {
if (isDefaultBackgroundAndTheme()) {
return defaultColor;
} else {
return extractDominantColors(background);
}
}
case HomeTabButtonColor::FromBackground:
return extractDominantColors(background);
}
return defaultColor;
}
void HomeWidget::setRandomCard(ExactCard &newCard)
{
static constexpr int ATTEMPTS = 10;
@ -171,7 +202,7 @@ void HomeWidget::updateBackgroundProperties()
void HomeWidget::updateButtonsToBackgroundColor()
{
gradientColors = extractDominantColors(background);
gradientColors = determineButtonColor();
for (HomeStyledButton *button : findChildren<HomeStyledButton *>()) {
button->updateStylesheet(gradientColors);
button->update();
@ -266,11 +297,6 @@ void HomeWidget::updateConnectButton(const ClientStatus status)
QPair<QColor, QColor> HomeWidget::extractDominantColors(const QPixmap &pixmap)
{
QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource();
if (themeManager->isBuiltInTheme() && BackgroundSources::fromId(sourceId) == BackgroundSources::Theme) {
return QPair<QColor, QColor>(QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80));
}
// Step 1: Downscale image for performance
QImage image = pixmap.toImage()
.scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)

View file

@ -23,7 +23,7 @@ class HomeWidget : public QWidget
public:
HomeWidget(QWidget *parent, TabSupervisor *tabSupervisor);
void updateRandomCard();
QPair<QColor, QColor> extractDominantColors(const QPixmap &pixmap);
static QPair<QColor, QColor> extractDominantColors(const QPixmap &pixmap);
public slots:
void paintEvent(QPaintEvent *event) override;
@ -47,6 +47,7 @@ private:
void setRandomCard(ExactCard &newCard);
void loadBackgroundSourceDeck();
QPair<QColor, QColor> determineButtonColor() const;
};
#endif // HOME_WIDGET_H

View file

@ -0,0 +1,250 @@
#ifndef BANNER_SHADER_CONFIG_H
#define BANNER_SHADER_CONFIG_H
#include <QColor>
#include <QObject>
/**
* Uniform values fed to brand_banner.frag, exposed to QML as the
* "bannerConfig" context property.
*
* Two independent "banks" (A/B) each carry their own mode/speed/seed so
* BrandBanner.qml can render both simultaneously and crossfade between
* them via opacity -- see frontIsA. The shared palette (colorA/colorB/
* accent) and clock (time/aspect) apply to both banks identically, since
* only the foreground motif changes between onboarding pages, never the
* brand palette.
*
* Deliberately plain `property` (not `required property`) on the QML side
* -- a required-property shadowing bug bit the home-screen particle
* background before, and there's no reason to reintroduce that risk here.
*/
class BannerShaderConfig : public QObject
{
Q_OBJECT
Q_PROPERTY(qreal time READ time WRITE setTime NOTIFY timeChanged)
Q_PROPERTY(qreal aspect READ aspect WRITE setAspect NOTIFY aspectChanged)
Q_PROPERTY(qreal modeA READ modeA WRITE setModeA NOTIFY modeAChanged)
Q_PROPERTY(qreal speedA READ speedA WRITE setSpeedA NOTIFY speedAChanged)
Q_PROPERTY(qreal seedA READ seedA WRITE setSeedA NOTIFY seedAChanged)
Q_PROPERTY(qreal modeB READ modeB WRITE setModeB NOTIFY modeBChanged)
Q_PROPERTY(qreal speedB READ speedB WRITE setSpeedB NOTIFY speedBChanged)
Q_PROPERTY(qreal seedB READ seedB WRITE setSeedB NOTIFY seedBChanged)
Q_PROPERTY(bool frontIsA READ frontIsA WRITE setFrontIsA NOTIFY frontIsAChanged)
Q_PROPERTY(QColor colorA READ colorA WRITE setColorA NOTIFY colorAChanged)
Q_PROPERTY(QColor colorB READ colorB WRITE setColorB NOTIFY colorBChanged)
Q_PROPERTY(QColor accent READ accent WRITE setAccent NOTIFY accentChanged)
Q_PROPERTY(bool logoVisible READ logoVisible WRITE setLogoVisible NOTIFY logoVisibleChanged)
Q_PROPERTY(qreal logoGlow READ logoGlow WRITE setLogoGlow NOTIFY logoGlowChanged)
public:
explicit BannerShaderConfig(QObject *parent = nullptr) : QObject(parent)
{
}
qreal time() const
{
return m_time;
}
void setTime(qreal v)
{
if (v != m_time) {
m_time = v;
emit timeChanged();
}
}
qreal aspect() const
{
return m_aspect;
}
void setAspect(qreal v)
{
if (v != m_aspect) {
m_aspect = v;
emit aspectChanged();
}
}
qreal modeA() const
{
return m_modeA;
}
void setModeA(qreal v)
{
if (v != m_modeA) {
m_modeA = v;
emit modeAChanged();
}
}
qreal speedA() const
{
return m_speedA;
}
void setSpeedA(qreal v)
{
if (v != m_speedA) {
m_speedA = v;
emit speedAChanged();
}
}
qreal seedA() const
{
return m_seedA;
}
void setSeedA(qreal v)
{
if (v != m_seedA) {
m_seedA = v;
emit seedAChanged();
}
}
qreal modeB() const
{
return m_modeB;
}
void setModeB(qreal v)
{
if (v != m_modeB) {
m_modeB = v;
emit modeBChanged();
}
}
qreal speedB() const
{
return m_speedB;
}
void setSpeedB(qreal v)
{
if (v != m_speedB) {
m_speedB = v;
emit speedBChanged();
}
}
qreal seedB() const
{
return m_seedB;
}
void setSeedB(qreal v)
{
if (v != m_seedB) {
m_seedB = v;
emit seedBChanged();
}
}
bool frontIsA() const
{
return m_frontIsA;
}
void setFrontIsA(bool v)
{
if (v != m_frontIsA) {
m_frontIsA = v;
emit frontIsAChanged();
}
}
QColor colorA() const
{
return m_colorA;
}
void setColorA(const QColor &c)
{
if (c != m_colorA) {
m_colorA = c;
emit colorAChanged();
}
}
QColor colorB() const
{
return m_colorB;
}
void setColorB(const QColor &c)
{
if (c != m_colorB) {
m_colorB = c;
emit colorBChanged();
}
}
QColor accent() const
{
return m_accent;
}
void setAccent(const QColor &c)
{
if (c != m_accent) {
m_accent = c;
emit accentChanged();
}
}
bool logoVisible() const
{
return m_logoVisible;
}
void setLogoVisible(bool v)
{
if (v != m_logoVisible) {
m_logoVisible = v;
emit logoVisibleChanged();
}
}
qreal logoGlow() const
{
return m_logoGlow;
}
void setLogoGlow(qreal v)
{
if (v != m_logoGlow) {
m_logoGlow = v;
emit logoGlowChanged();
}
}
signals:
void timeChanged();
void aspectChanged();
void modeAChanged();
void speedAChanged();
void seedAChanged();
void modeBChanged();
void speedBChanged();
void seedBChanged();
void frontIsAChanged();
void colorAChanged();
void colorBChanged();
void accentChanged();
void logoVisibleChanged();
void logoGlowChanged();
private:
qreal m_time = 0.0;
qreal m_aspect = 16.0 / 9.0;
qreal m_modeA = 0.0;
qreal m_speedA = 1.0;
qreal m_seedA = 0.0;
qreal m_modeB = 0.0;
qreal m_speedB = 1.0;
qreal m_seedB = 0.0;
bool m_frontIsA = true;
QColor m_colorA{0x1A, 0x1A, 0x20};
QColor m_colorB{0x0E, 0x0E, 0x12};
QColor m_accent{0x8B, 0xDD, 0x6B};
bool m_logoVisible = false;
qreal m_logoGlow = 1.0;
};
#endif // BANNER_SHADER_CONFIG_H

View file

@ -0,0 +1,218 @@
#include "first_run_wizard.h"
#include "first_run_wizard_page.h"
#include "pages/account_setup_page.h"
#include "pages/card_database_setup_page.h"
#include "pages/finish_page.h"
#include "pages/preferences_setup_page.h"
#include "pages/theme_setup_page.h"
#include "pages/welcome_page.h"
#include "shader_banner_widget.h"
#include "step_indicator_widget.h"
#include <QCloseEvent>
#include <QEvent>
#include <QFont>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QStackedWidget>
#include <QVBoxLayout>
FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent)
{
setWindowFlag(Qt::WindowContextHelpButtonHint, false);
setMinimumSize(640, 490);
resize(720, 550);
bannerHost = new BannerHost(this);
titleLabel = new QLabel(this);
QFont titleFont = titleLabel->font();
titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4);
titleFont.setBold(true);
titleLabel->setFont(titleFont);
subtitleLabel = new QLabel(this);
subtitleLabel->setWordWrap(true);
stack = new QStackedWidget(this);
stepIndicator = new StepIndicatorWidget(this);
backButton = new QPushButton(this);
skipButton = new QPushButton(this);
nextButton = new QPushButton(this);
nextButton->setDefault(true);
connect(backButton, &QPushButton::clicked, this, &FirstRunWizard::goBack);
connect(skipButton, &QPushButton::clicked, this, &FirstRunWizard::skip);
connect(nextButton, &QPushButton::clicked, this, &FirstRunWizard::goNext);
auto *headerLayout = new QVBoxLayout;
headerLayout->setContentsMargins(0, 0, 0, 0);
headerLayout->addWidget(bannerHost);
headerLayout->addSpacing(12);
headerLayout->addWidget(titleLabel);
headerLayout->addWidget(subtitleLabel);
auto *navLayout = new QHBoxLayout;
navLayout->addWidget(backButton);
navLayout->addWidget(skipButton);
navLayout->addStretch();
navLayout->addWidget(stepIndicator);
navLayout->addStretch();
navLayout->addWidget(nextButton);
auto *root = new QVBoxLayout(this);
root->addLayout(headerLayout);
root->addSpacing(8);
root->addWidget(stack, 1);
root->addSpacing(8);
root->addLayout(navLayout);
auto *welcome = new WelcomePage(this);
auto *cardDb = new CardDatabaseSetupPage(this);
auto *theme = new ThemeSetupPage(this);
auto *account = new AccountSetupPage(this);
auto *prefs = new PreferencesSetupPage(this);
auto *finishPg = new FinishPage(this);
cardDatabasePage = cardDb;
connect(cardDb, &CardDatabaseSetupPage::updateRequested, this, &FirstRunWizard::cardDatabaseUpdateRequested);
connect(cardDb, &CardDatabaseSetupPage::manualSetupRequested, this,
&FirstRunWizard::manualCardDatabaseSetupRequested);
connect(account, &AccountSetupPage::registerRequested, this, &FirstRunWizard::registerRequested);
connect(account, &AccountSetupPage::connectRequested, this, &FirstRunWizard::connectRequested);
connect(cardDb, &CardDatabaseSetupPage::advanceRequested, this, [this] {
if (stack->currentWidget() == cardDatabasePage) {
showPage(currentIndex + 1);
}
});
addPage(welcome);
addPage(cardDb);
addPage(theme);
addPage(account);
addPage(prefs);
addPage(finishPg);
stepIndicator->setStepCount(pages.count());
retranslateUi();
showPage(0);
}
void FirstRunWizard::addPage(FirstRunWizardPage *page)
{
pages.append(page);
stack->addWidget(page);
connect(page, &FirstRunWizardPage::completeChanged, this, &FirstRunWizard::updateChrome);
}
void FirstRunWizard::showPage(int index)
{
if (index < 0 || index >= pages.count()) {
return;
}
currentIndex = index;
stack->setCurrentIndex(index);
pages[index]->initializePage();
stepIndicator->setCurrentStep(index);
static const QList<BannerHost::Motif> motifs = {
BannerHost::Motif::Welcome, BannerHost::Motif::CardDatabase, BannerHost::Motif::Theming,
BannerHost::Motif::Account, BannerHost::Motif::Preferences, BannerHost::Motif::Finish,
};
if (index < motifs.size()) {
bannerHost->setMotif(motifs[index]);
}
titleLabel->setText(pages[index]->stepTitle());
subtitleLabel->setText(pages[index]->stepSubtitle());
subtitleLabel->setVisible(!pages[index]->stepSubtitle().isEmpty());
updateChrome();
}
void FirstRunWizard::updateChrome()
{
if (currentIndex < 0) {
return;
}
FirstRunWizardPage *page = pages[currentIndex];
const bool isLast = (currentIndex == pages.count() - 1);
backButton->setVisible(currentIndex > 0);
skipButton->setVisible(page->isSkippable());
nextButton->setEnabled(page->isComplete());
QString customText = page->nextButtonText();
if (!customText.isEmpty()) {
nextButton->setText(customText);
} else {
nextButton->setText(isLast ? tr("Finish") : tr("Next"));
}
}
void FirstRunWizard::goNext()
{
FirstRunWizardPage *page = pages[currentIndex];
if (!page->validatePage() || !page->handleNextClick()) {
return;
}
if (currentIndex == pages.count() - 1) {
finish();
return;
}
showPage(currentIndex + 1);
}
void FirstRunWizard::goBack()
{
showPage(currentIndex - 1);
}
void FirstRunWizard::skip()
{
showPage(currentIndex + 1);
}
void FirstRunWizard::onCardDatabaseUpdateFinished(bool success)
{
if (cardDatabasePage) {
cardDatabasePage->onUpdateFinished(success);
}
}
void FirstRunWizard::finish()
{
accept();
}
void FirstRunWizard::closeEvent(QCloseEvent *event)
{
// Every step persists its own choice as it's made, so closing early
// isn't destructive -- treat it exactly like reaching the end.
QDialog::closeEvent(event);
}
void FirstRunWizard::changeEvent(QEvent *event)
{
if (event->type() == QEvent::LanguageChange) {
retranslateUi();
}
QDialog::changeEvent(event);
}
void FirstRunWizard::retranslateUi()
{
setWindowTitle(tr("Welcome to Cockatrice"));
backButton->setText(tr("Back"));
skipButton->setText(tr("Skip"));
for (FirstRunWizardPage *page : std::as_const(pages)) {
page->retranslateUi();
}
if (currentIndex >= 0) {
titleLabel->setText(pages[currentIndex]->stepTitle());
subtitleLabel->setText(pages[currentIndex]->stepSubtitle());
}
updateChrome();
}

View file

@ -0,0 +1,71 @@
#ifndef FIRST_RUN_WIZARD_H
#define FIRST_RUN_WIZARD_H
#include <QDialog>
#include <QList>
class BannerHost;
class FirstRunWizardPage;
class StepIndicatorWidget;
class CardDatabaseSetupPage;
class QLabel;
class QPushButton;
class QStackedWidget;
/** @brief Polished first-run onboarding flow: card database setup, theme
* selection, server account setup, and a handful of key preferences.
*
* Deliberately ignorant of network/registration/download internals --
* pages that need them emit request signals for MainWindow to fulfill.
* Every choice is written to SettingsCache as it's made (via the pages
* themselves, same as AppearanceSettingsPage does), so "Skip" or closing
* the window never discards anything already confirmed. */
class FirstRunWizard : public QDialog
{
Q_OBJECT
public:
explicit FirstRunWizard(QWidget *parent = nullptr);
signals:
void registerRequested();
void connectRequested();
void cardDatabaseUpdateRequested();
void manualCardDatabaseSetupRequested();
public slots:
/** @brief Forwarded from MainWindow once the background card database update process exits. */
void onCardDatabaseUpdateFinished(bool success);
protected:
void closeEvent(QCloseEvent *event) override;
void changeEvent(QEvent *event) override;
private slots:
void goNext();
void goBack();
void skip();
void updateChrome();
private:
void addPage(FirstRunWizardPage *page);
void showPage(int index);
void retranslateUi();
void finish();
QStackedWidget *stack;
StepIndicatorWidget *stepIndicator;
BannerHost *bannerHost;
QLabel *titleLabel;
QLabel *subtitleLabel;
QPushButton *backButton;
QPushButton *skipButton;
QPushButton *nextButton;
CardDatabaseSetupPage *cardDatabasePage = nullptr;
QList<FirstRunWizardPage *> pages;
int currentIndex = -1;
};
#endif // FIRST_RUN_WIZARD_H

View file

@ -0,0 +1 @@
#include "first_run_wizard_page.h"

View file

@ -0,0 +1,75 @@
#ifndef FIRST_RUN_WIZARD_PAGE_H
#define FIRST_RUN_WIZARD_PAGE_H
#include <QWidget>
/** @brief Base class for a single step of FirstRunWizard.
*
* QWidget-based rather than QWizardPage-based: FirstRunWizard is a
* QDialog + QStackedWidget shell (not a QWizard) so it can own the
* banner/step-dot chrome that QWizard's native styles don't give us
* consistent control over. Naming mirrors OracleWizardPage for
* familiarity only -- the two hierarchies are unrelated. */
class FirstRunWizardPage : public QWidget
{
Q_OBJECT
public:
explicit FirstRunWizardPage(QWidget *parent = nullptr) : QWidget(parent)
{
}
/** @brief Called every time the page becomes visible, including navigating back to it. */
virtual void initializePage()
{
}
/** @brief Called before advancing past this page. Return false to block navigation;
the page itself is responsible for telling the user why. */
virtual bool validatePage()
{
return true;
}
/** @brief Whether Next/Finish should currently be enabled. Pages doing async work
can flip this mid-step; emit completeChanged() when they do. */
virtual bool isComplete() const
{
return true;
}
/** @brief Whether the wizard's "Skip" button should be offered on this page. */
virtual bool isSkippable() const
{
return false;
}
virtual QString stepTitle() const = 0;
virtual QString stepSubtitle() const
{
return {};
}
/** @brief Override to replace the "Next"/"Finish" button text on this page.
Return an empty string to use the default label. */
virtual QString nextButtonText() const
{
return {};
}
/** @brief Called when the user presses the Next button. Return true to allow
advancing to the next page, false to stay on this page (e.g. to
trigger an async action first). */
virtual bool handleNextClick()
{
return true;
}
virtual void retranslateUi() = 0;
signals:
void completeChanged();
void advanceRequested();
};
#endif // FIRST_RUN_WIZARD_PAGE_H

View file

@ -0,0 +1,56 @@
#include "account_setup_page.h"
#include <QLabel>
#include <QPushButton>
#include <QVBoxLayout>
AccountSetupPage::AccountSetupPage(QWidget *parent) : FirstRunWizardPage(parent)
{
bodyLabel = new QLabel(this);
bodyLabel->setWordWrap(true);
bodyLabel->setAlignment(Qt::AlignCenter);
registerButton = new QPushButton(this);
connectButton = new QPushButton(this);
skipHintLabel = new QLabel(this);
skipHintLabel->setWordWrap(true);
skipHintLabel->setAlignment(Qt::AlignCenter);
connect(registerButton, &QPushButton::clicked, this, &AccountSetupPage::registerRequested);
connect(connectButton, &QPushButton::clicked, this, &AccountSetupPage::connectRequested);
auto *layout = new QVBoxLayout(this);
layout->addStretch();
layout->addWidget(bodyLabel);
layout->addSpacing(16);
layout->addWidget(registerButton, 0, Qt::AlignHCenter);
layout->addWidget(connectButton, 0, Qt::AlignHCenter);
layout->addSpacing(16);
layout->addWidget(skipHintLabel);
layout->addStretch();
retranslateUi();
}
bool AccountSetupPage::isSkippable() const
{
return true;
}
QString AccountSetupPage::stepTitle() const
{
return tr("Join a Server");
}
QString AccountSetupPage::stepSubtitle() const
{
return tr("Optional — you can always do this later from the menu.");
}
void AccountSetupPage::retranslateUi()
{
bodyLabel->setText(tr("Playing online needs a server account."));
registerButton->setText(tr("Register a new account…"));
connectButton->setText(tr("I already have one — Connect…"));
skipHintLabel->setText(tr("Just want to play locally? Skip this and connect whenever you're ready."));
}

View file

@ -0,0 +1,38 @@
#ifndef ACCOUNT_SETUP_PAGE_H
#define ACCOUNT_SETUP_PAGE_H
#include "../first_run_wizard_page.h"
class QLabel;
class QPushButton;
/** @brief First-run account step. Does NOT embed DlgRegister's fields: they exist
* to be handed to ConnectionController's network registration flow, which
* this wizard has no visibility into. Reimplementing the fields here
* without that wiring would look functional and silently do nothing --
* worse than reuse. So: a friendly landing spot that opens the *existing*
* DlgRegister / connect flow via signals FirstRunWizard forwards. */
class AccountSetupPage : public FirstRunWizardPage
{
Q_OBJECT
public:
explicit AccountSetupPage(QWidget *parent = nullptr);
bool isSkippable() const override;
QString stepTitle() const override;
QString stepSubtitle() const override;
void retranslateUi() override;
signals:
void registerRequested();
void connectRequested();
private:
QLabel *bodyLabel;
QPushButton *registerButton;
QPushButton *connectButton;
QLabel *skipHintLabel;
};
#endif // ACCOUNT_SETUP_PAGE_H

View file

@ -0,0 +1,314 @@
#include "card_database_setup_page.h"
#include "../../client/settings/cache_settings.h"
#include <QComboBox>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QProgressBar>
#include <QPushButton>
#include <QSettings>
#include <QSpinBox>
#include <QTimer>
#include <QUrl>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/updates_settings.h>
CardDatabaseSetupPage::CardDatabaseSetupPage(QWidget *parent) : FirstRunWizardPage(parent)
{
statusLabel = new QLabel(this);
statusLabel->setWordWrap(true);
statusLabel->setAlignment(Qt::AlignCenter);
progressBar = new QProgressBar(this);
progressBar->setRange(0, 0);
progressBar->setTextVisible(false);
progressBar->setFixedWidth(280);
retryButton = new QPushButton(this);
manualButton = new QPushButton(this);
connect(retryButton, &QPushButton::clicked, this, [this] {
setState(State::Running);
emit updateRequested();
});
connect(manualButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::manualSetupRequested);
// ── Advanced: custom download source ───────────────────────────────
advancedToggleButton = new QPushButton(this);
advancedToggleButton->setCheckable(true);
advancedToggleButton->setChecked(false);
advancedToggleButton->setFlat(true);
advancedToggleButton->setStyleSheet("QPushButton { text-align: left; padding: 5px 12px; font-weight: bold; }"
"QPushButton:checked { }");
advancedPanel = new QWidget(this);
advancedPanel->setVisible(false);
urlLineEdit = new QLineEdit(advancedPanel);
urlHintLabel = new QLabel(advancedPanel);
urlHintLabel->setWordWrap(true);
restoreDefaultUrlButton = new QPushButton(advancedPanel);
applyAndRetryButton = new QPushButton(advancedPanel);
connect(advancedToggleButton, &QPushButton::toggled, this, &CardDatabaseSetupPage::onToggleAdvanced);
connect(restoreDefaultUrlButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::onRestoreDefaultUrl);
connect(applyAndRetryButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::onApplyCustomUrl);
auto *advancedButtonRow = new QHBoxLayout;
advancedButtonRow->addWidget(restoreDefaultUrlButton);
advancedButtonRow->addStretch();
advancedButtonRow->addWidget(applyAndRetryButton);
auto *advancedLayout = new QVBoxLayout(advancedPanel);
advancedLayout->setContentsMargins(12, 4, 12, 4);
advancedLayout->addWidget(urlLineEdit);
advancedLayout->addWidget(urlHintLabel);
advancedLayout->addLayout(advancedButtonRow);
// ── Startup card update check ───────────────────────────────────────
auto &upd = SettingsCache::instance().updates();
const auto updateBehavior = [this] {
auto &u = SettingsCache::instance().updates();
int idx = startupBehaviorCombo->currentIndex();
u.setStartupCardUpdateCheckPromptForUpdate(idx == 1);
u.setStartupCardUpdateCheckAlwaysUpdate(idx == 2);
};
startupBehaviorLabel = new QLabel(this);
startupBehaviorCombo = new QComboBox(this);
startupBehaviorCombo->addItem(QString()); // placeholder, filled in retranslateUi
startupBehaviorCombo->addItem(QString());
startupBehaviorCombo->addItem(QString());
if (upd.getStartupCardUpdateCheckPromptForUpdate()) {
startupBehaviorCombo->setCurrentIndex(1);
} else if (upd.getStartupCardUpdateCheckAlwaysUpdate()) {
startupBehaviorCombo->setCurrentIndex(2);
} else {
startupBehaviorCombo->setCurrentIndex(0);
}
connect(startupBehaviorCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, updateBehavior);
checkIntervalLabel = new QLabel(this);
checkIntervalSpinBox = new QSpinBox(this);
checkIntervalSpinBox->setMinimum(1);
checkIntervalSpinBox->setMaximum(30);
checkIntervalSpinBox->setValue(upd.getCardUpdateCheckInterval());
connect(checkIntervalSpinBox, QOverload<int>::of(&QSpinBox::valueChanged), &upd,
&UpdatesSettings::setCardUpdateCheckInterval);
auto *checkGrid = new QGridLayout;
checkGrid->addWidget(startupBehaviorLabel, 0, 0);
checkGrid->addWidget(startupBehaviorCombo, 0, 1);
checkGrid->addWidget(checkIntervalLabel, 1, 0);
checkGrid->addWidget(checkIntervalSpinBox, 1, 1);
auto *layout = new QVBoxLayout(this);
layout->addStretch();
layout->addWidget(statusLabel);
layout->addSpacing(12);
layout->addWidget(progressBar, 0, Qt::AlignHCenter);
layout->addSpacing(12);
layout->addWidget(retryButton, 0, Qt::AlignHCenter);
layout->addWidget(manualButton, 0, Qt::AlignHCenter);
layout->addSpacing(16);
layout->addWidget(advancedToggleButton);
layout->addWidget(advancedPanel);
layout->addSpacing(8);
layout->addLayout(checkGrid);
layout->addStretch();
retranslateUi();
}
bool CardDatabaseSetupPage::alreadyHaveDatabase() const
{
return CardDatabaseManager::getInstance()->getCardList().count() > 0;
}
QString CardDatabaseSetupPage::oracleSettingsFilePath() const
{
return SettingsCache::instance().getSettingsPath() + "oracle.ini";
}
QString CardDatabaseSetupPage::readCustomUrl() const
{
QSettings oracleSettings(oracleSettingsFilePath(), QSettings::IniFormat);
return oracleSettings.value("allsetsurl").toString();
}
void CardDatabaseSetupPage::writeCustomUrl(const QString &url)
{
QSettings oracleSettings(oracleSettingsFilePath(), QSettings::IniFormat);
if (url.isEmpty()) {
oracleSettings.remove("allsetsurl");
} else {
oracleSettings.setValue("allsetsurl", url);
}
}
void CardDatabaseSetupPage::initializePage()
{
urlLineEdit->setText(readCustomUrl());
if (state != State::NotStarted) {
return;
}
if (alreadyHaveDatabase()) {
setState(State::Succeeded);
return;
}
// Don't auto-download — wait for the user to press "Download".
setState(State::NotStarted);
statusLabel->setText(tr("Press Download to fetch the card database, or Skip to do it later."));
}
void CardDatabaseSetupPage::onUpdateFinished(bool success)
{
setState(success ? State::Succeeded : State::Failed);
if (success) {
emit advanceRequested();
}
}
QString CardDatabaseSetupPage::nextButtonText() const
{
return state == State::NotStarted ? tr("Download") : QString();
}
bool CardDatabaseSetupPage::handleNextClick()
{
if (state == State::NotStarted) {
setState(State::Running);
emit updateRequested();
return false;
}
return true;
}
void CardDatabaseSetupPage::onToggleAdvanced(bool open)
{
advancedToggleButton->setText(open ? tr("▼ Advanced: custom download source")
: tr("▶ Advanced: custom download source"));
advancedPanel->setVisible(open);
QWidget *wizardWindow = window();
if (!wizardWindow) {
return;
}
if (open) {
windowSizeBeforeExpansion = wizardWindow->size();
QTimer::singleShot(0, this, [wizardWindow] {
wizardWindow->resize(wizardWindow->size().expandedTo(wizardWindow->sizeHint()));
});
} else {
QTimer::singleShot(0, this, [this, wizardWindow] {
wizardWindow->resize(wizardWindow->size().boundedTo(windowSizeBeforeExpansion));
});
}
}
void CardDatabaseSetupPage::onApplyCustomUrl()
{
const QString text = urlLineEdit->text().trimmed();
if (!text.isEmpty()) {
const QUrl url = QUrl::fromUserInput(text);
if (!url.isValid()) {
QMessageBox::warning(this, tr("Invalid URL"),
tr("That doesn't look like a valid URL. Double-check it and try again, "
"or clear the field to use the default source."));
return;
}
}
writeCustomUrl(text);
setState(State::Running);
emit updateRequested();
}
void CardDatabaseSetupPage::onRestoreDefaultUrl()
{
urlLineEdit->clear();
writeCustomUrl(QString());
}
void CardDatabaseSetupPage::setState(State newState)
{
state = newState;
progressBar->setVisible(state == State::Running);
retryButton->setVisible(state == State::Failed);
manualButton->setVisible(state == State::Failed);
applyAndRetryButton->setEnabled(state != State::Running);
switch (state) {
case State::NotStarted:
statusLabel->setText(tr("Press Download to fetch the card database, or Skip to do it later."));
break;
case State::Running:
statusLabel->setText(tr("Downloading the latest card database…"));
break;
case State::Succeeded:
statusLabel->setText(tr("Card database ready ✓"));
break;
case State::Failed:
statusLabel->setText(
tr("Couldn't download the card database automatically. Check your connection and retry, "
"set it up manually, or skip this for now — you can do it later from the Card Database menu."));
break;
}
emit completeChanged();
}
bool CardDatabaseSetupPage::isComplete() const
{
return state != State::Running;
}
bool CardDatabaseSetupPage::isSkippable() const
{
return state != State::Succeeded;
}
QString CardDatabaseSetupPage::stepTitle() const
{
return tr("Card Database");
}
QString CardDatabaseSetupPage::stepSubtitle() const
{
return tr("Cockatrice needs card data to know what you're playing with.");
}
void CardDatabaseSetupPage::retranslateUi()
{
retryButton->setText(tr("Retry"));
manualButton->setText(tr("Set up manually…"));
onToggleAdvanced(advancedToggleButton->isChecked());
urlLineEdit->setPlaceholderText(tr("Leave blank to use the default source"));
urlHintLabel->setText(tr("Only change this if you know you need a mirror or a custom card data source."));
restoreDefaultUrlButton->setText(tr("Restore default"));
applyAndRetryButton->setText(tr("Apply && retry"));
startupBehaviorLabel->setText(tr("Check for card database updates on startup"));
startupBehaviorCombo->setItemText(0, tr("Don't check"));
startupBehaviorCombo->setItemText(1, tr("Prompt for update"));
startupBehaviorCombo->setItemText(2, tr("Always update in the background"));
checkIntervalLabel->setText(tr("Check for card database updates every"));
checkIntervalSpinBox->setSuffix(tr(" days"));
setState(state);
}

View file

@ -0,0 +1,79 @@
#ifndef CARD_DATABASE_SETUP_PAGE_H
#define CARD_DATABASE_SETUP_PAGE_H
#include "../first_run_wizard_page.h"
#include <QSize>
class QComboBox;
class QLabel;
class QLineEdit;
class QProgressBar;
class QPushButton;
class QSpinBox;
class QWidget;
class CardDatabaseSetupPage : public FirstRunWizardPage
{
Q_OBJECT
public:
explicit CardDatabaseSetupPage(QWidget *parent = nullptr);
void initializePage() override;
bool isComplete() const override;
bool isSkippable() const override;
QString stepTitle() const override;
QString stepSubtitle() const override;
QString nextButtonText() const override;
bool handleNextClick() override;
void retranslateUi() override;
void onUpdateFinished(bool success);
signals:
void updateRequested();
void manualSetupRequested();
private:
enum class State
{
NotStarted,
Running,
Succeeded,
Failed,
};
void setState(State newState);
bool alreadyHaveDatabase() const;
QString oracleSettingsFilePath() const;
QString readCustomUrl() const;
void writeCustomUrl(const QString &url);
void onToggleAdvanced(bool open);
void onApplyCustomUrl();
void onRestoreDefaultUrl();
QLabel *statusLabel;
QProgressBar *progressBar;
QPushButton *retryButton;
QPushButton *manualButton;
QPushButton *advancedToggleButton;
QWidget *advancedPanel;
QLineEdit *urlLineEdit;
QLabel *urlHintLabel;
QPushButton *restoreDefaultUrlButton;
QPushButton *applyAndRetryButton;
QLabel *startupBehaviorLabel;
QComboBox *startupBehaviorCombo;
QLabel *checkIntervalLabel;
QSpinBox *checkIntervalSpinBox;
State state = State::NotStarted;
QSize windowSizeBeforeExpansion;
};
#endif // CARD_DATABASE_SETUP_PAGE_H

View file

@ -0,0 +1,30 @@
#include "finish_page.h"
#include <QLabel>
#include <QVBoxLayout>
FinishPage::FinishPage(QWidget *parent) : FirstRunWizardPage(parent)
{
bodyLabel = new QLabel(this);
bodyLabel->setWordWrap(true);
bodyLabel->setAlignment(Qt::AlignCenter);
auto *layout = new QVBoxLayout(this);
layout->addStretch();
layout->addWidget(bodyLabel);
layout->addStretch();
retranslateUi();
}
QString FinishPage::stepTitle() const
{
return tr("You're All Set");
}
void FinishPage::retranslateUi()
{
bodyLabel->setText(
tr("That's everything for now. Jump into Settings any time to change your mind about any of this.\n\n"
"Have fun!"));
}

View file

@ -0,0 +1,22 @@
#ifndef FINISH_PAGE_H
#define FINISH_PAGE_H
#include "../first_run_wizard_page.h"
class QLabel;
class FinishPage : public FirstRunWizardPage
{
Q_OBJECT
public:
explicit FinishPage(QWidget *parent = nullptr);
QString stepTitle() const override;
void retranslateUi() override;
private:
QLabel *bodyLabel;
};
#endif // FINISH_PAGE_H

View file

@ -0,0 +1,173 @@
#include "preferences_setup_page.h"
#include "../../client/settings/cache_settings.h"
#include "../../client/sound_engine.h"
#include "libcockatrice/settings/interface_settings.h"
#include "libcockatrice/settings/sound_settings.h"
#include "libcockatrice/settings/tabs_settings.h"
#include <QCheckBox>
#include <QComboBox>
#include <QFormLayout>
#include <QGroupBox>
#include <QLabel>
#include <QScrollArea>
#include <QVBoxLayout>
namespace
{
// The server destinations are omitted: during first run their tabs are not
// open yet, and the wizard offers no way to fill in the server/room details.
QList<StartupTab> wizardStartupTabOrder()
{
return {StartupTabHome, StartupTabVisualDeckStorage, StartupTabDeckStorage,
StartupTabReplays, StartupTabDeckEditor, StartupTabVisualDeckEditor};
}
} // namespace
PreferencesSetupPage::PreferencesSetupPage(QWidget *parent) : FirstRunWizardPage(parent)
{
auto *content = new QWidget;
auto *contentLayout = new QVBoxLayout(content);
gameplayGroup = new QGroupBox(content);
auto *gameplayLayout = new QVBoxLayout(gameplayGroup);
contentLayout->addWidget(gameplayGroup);
doubleClickToPlayCheckBox = new QCheckBox(gameplayGroup);
horizontalHandCheckBox = new QCheckBox(gameplayGroup);
playToStackCheckBox = new QCheckBox(gameplayGroup);
gameplayLayout->addWidget(doubleClickToPlayCheckBox);
gameplayLayout->addWidget(horizontalHandCheckBox);
gameplayLayout->addWidget(playToStackCheckBox);
notificationsGroup = new QGroupBox(content);
auto *notificationsLayout = new QVBoxLayout(notificationsGroup);
contentLayout->addWidget(notificationsGroup);
notificationsEnabledCheckBox = new QCheckBox(notificationsGroup);
soundEnabledCheckBox = new QCheckBox(notificationsGroup);
notificationsLayout->addWidget(notificationsEnabledCheckBox);
notificationsLayout->addWidget(soundEnabledCheckBox);
startupGroup = new QGroupBox(content);
auto *startupForm = new QFormLayout(startupGroup);
contentLayout->addWidget(startupGroup);
startupTabLabel = new QLabel(startupGroup);
startupTabSelector = new QComboBox(startupGroup);
startupTabSelector->setSizeAdjustPolicy(QComboBox::AdjustToContents);
for (StartupTab tab : wizardStartupTabOrder()) {
startupTabSelector->addItem(QString(), tab); // texts set in retranslateUi
}
startupForm->addRow(startupTabLabel, startupTabSelector);
contentLayout->addStretch();
auto *scrollArea = new QScrollArea(this);
scrollArea->setWidget(content);
scrollArea->setWidgetResizable(true);
scrollArea->setFrameShape(QFrame::NoFrame);
auto *layout = new QVBoxLayout(this);
layout->addWidget(scrollArea);
SettingsCache &settings = SettingsCache::instance();
connect(doubleClickToPlayCheckBox, &QCheckBox::toggled, &settings.userInterface(),
&InterfaceSettings::setDoubleClickToPlay);
connect(horizontalHandCheckBox, &QCheckBox::toggled, &settings.userInterface(),
&InterfaceSettings::setHorizontalHand);
connect(playToStackCheckBox, &QCheckBox::toggled, &settings.userInterface(), &InterfaceSettings::setPlayToStack);
connect(notificationsEnabledCheckBox, &QCheckBox::toggled, &settings.userInterface(),
&InterfaceSettings::setNotificationsEnabled);
connect(soundEnabledCheckBox, &QCheckBox::toggled, &settings.sound(), &SoundSettings::setSoundEnabled);
connect(soundEnabledCheckBox, &QCheckBox::toggled, soundEngine, &SoundEngine::testSound);
connect(startupTabSelector, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index) {
if (index < 0) {
return;
}
SettingsCache::instance().tabs().setStartupTabIndex(startupTabSelector->itemData(index).toInt());
});
retranslateUi();
}
void PreferencesSetupPage::initializePage()
{
SettingsCache &settings = SettingsCache::instance();
doubleClickToPlayCheckBox->setChecked(settings.userInterface().getDoubleClickToPlay());
horizontalHandCheckBox->setChecked(settings.userInterface().getHorizontalHand());
playToStackCheckBox->setChecked(settings.userInterface().getPlayToStack());
notificationsEnabledCheckBox->setChecked(settings.userInterface().getNotificationsEnabled());
soundEnabledCheckBox->setChecked(settings.sound().getSoundEnabled());
startupTabSelector->setCurrentIndex(startupTabSelector->findData(settings.tabs().getStartupTabIndex()));
}
bool PreferencesSetupPage::isSkippable() const
{
return true;
}
QString PreferencesSetupPage::stepTitle() const
{
return tr("A Few Preferences");
}
QString PreferencesSetupPage::stepSubtitle() const
{
return tr("Defaults are fine — tweak these now or from Settings anytime.");
}
void PreferencesSetupPage::retranslateUi()
{
gameplayGroup->setTitle(tr("Gameplay"));
doubleClickToPlayCheckBox->setText(tr("Double-click cards to play them"));
doubleClickToPlayCheckBox->setToolTip(tr("When disabled, a single click plays the selected card onto the table."));
horizontalHandCheckBox->setText(tr("Display hand horizontally"));
horizontalHandCheckBox->setToolTip(
tr("Shows your hand as a row along the bottom of the table instead of a column beside it."));
playToStackCheckBox->setText(tr("Play all nonlands onto the stack by default"));
playToStackCheckBox->setToolTip(
tr("Cards you play appear on the stack so other players can respond to them, as in a tabletop game."));
notificationsGroup->setTitle(tr("Notifications && Sound"));
notificationsEnabledCheckBox->setText(tr("Show desktop notifications"));
soundEnabledCheckBox->setText(tr("Play sound effects"));
startupGroup->setTitle(tr("Startup"));
startupTabLabel->setText(tr("Startup tab:"));
const QList<StartupTab> tabs = wizardStartupTabOrder();
for (int i = 0; i < tabs.size(); ++i) {
QString name;
switch (tabs[i]) {
case StartupTabHome:
name = tr("Home");
break;
case StartupTabVisualDeckStorage:
name = tr("Visual Deck Storage");
break;
case StartupTabDeckStorage:
name = tr("Deck Storage");
break;
case StartupTabReplays:
name = tr("Game Replays");
break;
case StartupTabDeckEditor:
name = tr("Deck Editor");
break;
case StartupTabVisualDeckEditor:
name = tr("Visual Deck Editor");
break;
case StartupTabServer:
name = tr("Server");
break;
case StartupTabServerRoom:
name = tr("Server Room");
break;
}
startupTabSelector->setItemText(i, name);
}
}

View file

@ -0,0 +1,41 @@
#ifndef PREFERENCES_SETUP_PAGE_H
#define PREFERENCES_SETUP_PAGE_H
#include "../first_run_wizard_page.h"
class QCheckBox;
class QComboBox;
class QGroupBox;
class QLabel;
/** @brief A curated subset of settings for the user to adjust.
**/
class PreferencesSetupPage : public FirstRunWizardPage
{
Q_OBJECT
public:
explicit PreferencesSetupPage(QWidget *parent = nullptr);
void initializePage() override;
bool isSkippable() const override;
QString stepTitle() const override;
QString stepSubtitle() const override;
void retranslateUi() override;
private:
QGroupBox *gameplayGroup;
QCheckBox *doubleClickToPlayCheckBox;
QCheckBox *horizontalHandCheckBox;
QCheckBox *playToStackCheckBox;
QGroupBox *notificationsGroup;
QCheckBox *notificationsEnabledCheckBox;
QCheckBox *soundEnabledCheckBox;
QGroupBox *startupGroup;
QLabel *startupTabLabel;
QComboBox *startupTabSelector;
};
#endif // PREFERENCES_SETUP_PAGE_H

View file

@ -0,0 +1,231 @@
#include "theme_setup_page.h"
#include "../../client/settings/cache_settings.h"
#include "../../interface/palette_editor/palette_generator.h"
#include "../../interface/palette_editor/quick_setup_panel.h"
#include "../../interface/theme_manager.h"
#include "../../interface/widgets/general/background_sources.h"
#include "libcockatrice/settings/appearance_settings.h"
#include <QComboBox>
#include <QDir>
#include <QFile>
#include <QFormLayout>
#include <QGroupBox>
#include <QLabel>
#include <QMessageBox>
#include <QVBoxLayout>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent)
{
themeCombo = new QComboBox(this);
schemeCombo = new QComboBox(this);
schemeCombo->addItem(tr("Light"), QStringLiteral("Light"));
schemeCombo->addItem(tr("Dark"), QStringLiteral("Dark"));
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
schemeCombo->addItem(tr("Match system"), QStringLiteral("System"));
#endif
quickSetupPanel = new QuickSetupPanel(this);
connect(themeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onThemeChanged);
connect(schemeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onSchemeChanged);
connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &ThemeSetupPage::onGenerateFromAccent);
homeTabBackgroundCombo = new QComboBox(this);
for (const auto &entry : BackgroundSources::all()) {
homeTabBackgroundCombo->addItem(QObject::tr(entry.trKey), QVariant::fromValue(entry.type));
}
connect(homeTabBackgroundCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
&ThemeSetupPage::onHomeTabBackgroundChanged);
// Keep the scheme combo honest when the *theme* changes underneath it
// (switching theme reloads that theme's own stored colorScheme), and
// opportunistically seed a palette for themes that ship none at all.
// Mirrors AppearanceSettingsPage's identical listener for the combo-sync
// half of this.
connect(themeManager, &ThemeManager::themeChanged, this, [this] {
const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
const ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir);
const QString current = cfg.colorScheme;
schemeCombo->blockSignals(true);
const int idx = schemeCombo->findData(current);
schemeCombo->setCurrentIndex(idx >= 0 ? idx : 0);
schemeCombo->blockSignals(false);
maybeAutoGeneratePalette();
});
auto *form = new QFormLayout;
form->addRow(tr("Theme:"), themeCombo);
form->addRow(tr("Appearance:"), schemeCombo);
form->addRow(tr("Home screen background:"), homeTabBackgroundCombo);
accentGroup = new QGroupBox(this);
auto *accentLayout = new QVBoxLayout(accentGroup);
accentLayout->addWidget(quickSetupPanel);
auto *layout = new QVBoxLayout(this);
layout->addLayout(form);
layout->addWidget(accentGroup);
layout->addStretch();
retranslateUi();
}
void ThemeSetupPage::initializePage()
{
themeCombo->blockSignals(true);
themeCombo->clear();
const QString currentTheme = SettingsCache::instance().getThemeName();
for (const QString &name : themeManager->getAvailableThemes().keys()) {
themeCombo->addItem(name);
}
const int idx = themeCombo->findText(currentTheme);
themeCombo->setCurrentIndex(idx >= 0 ? idx : 0);
themeCombo->blockSignals(false);
const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
const ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
schemeCombo->blockSignals(true);
const int schemeIdx = schemeCombo->findData(cfg.colorScheme);
schemeCombo->setCurrentIndex(schemeIdx >= 0 ? schemeIdx : 0);
schemeCombo->blockSignals(false);
homeTabBackgroundCombo->blockSignals(true);
QString homeTabSource = SettingsCache::instance().appearance().getHomeTabBackgroundSource();
int homeTabIdx = homeTabBackgroundCombo->findData(BackgroundSources::fromId(homeTabSource));
homeTabBackgroundCombo->setCurrentIndex(homeTabIdx >= 0 ? homeTabIdx : 0);
homeTabBackgroundCombo->blockSignals(false);
// Opening the page must not touch the running application's palette:
// previews and auto-generation only happen in response to the user
// actually changing a control, never on mere page visibility.
paletteDirty = false;
}
QString ThemeSetupPage::currentScheme() const
{
return schemeCombo->currentData().toString();
}
QString ThemeSetupPage::resolvedScheme() const
{
const QString scheme = currentScheme();
if (scheme.isEmpty() || scheme == QStringLiteral("System")) {
return themeManager->isDarkMode(themeManager->getCurrentThemePath()) ? "Dark" : "Light";
}
return scheme;
}
void ThemeSetupPage::onThemeChanged(int index)
{
if (index < 0) {
return;
}
paletteDirty = false;
SettingsCache::instance().setThemeName(themeCombo->itemText(index));
// Scheme-combo sync and auto-generation both happen via the
// ThemeManager::themeChanged listener above, triggered by setThemeName.
}
void ThemeSetupPage::onSchemeChanged()
{
themeManager->setColorScheme(currentScheme());
}
void ThemeSetupPage::onHomeTabBackgroundChanged(int index)
{
if (index < 0) {
return;
}
auto type = homeTabBackgroundCombo->currentData().value<BackgroundSources::Type>();
SettingsCache::instance().appearance().setHomeTabBackgroundSource(BackgroundSources::toId(type));
}
void ThemeSetupPage::onGenerateFromAccent(const QColor &accent, int intensity)
{
PaletteConfig cfg = PaletteGenerator::fromAccent(accent, intensity, resolvedScheme());
themeManager->previewPalette(cfg, resolvedScheme());
paletteDirty = true;
}
void ThemeSetupPage::maybeAutoGeneratePalette()
{
const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
const QString scheme = resolvedScheme();
if (PaletteConfig::fromScheme(dirPath, scheme).hasPalette() ||
PaletteConfig::fromDefault(dirPath, scheme).hasPalette()) {
return; // theme already has something real to show -- leave it alone
}
// The theme+scheme combination has nothing saved and nothing shipped, and
// the user just switched to it. Rather than leaving a flat, unstyled look,
// seed one from whatever accent QuickSetupPanel currently holds and mark
// it dirty so it's written to disk if the user moves on. Only ever reached
// through user interaction (theme/scheme change, accent drag) -- never on
// page open.
PaletteConfig generated =
PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme);
themeManager->previewPalette(generated, scheme);
paletteDirty = true;
}
bool ThemeSetupPage::validatePage()
{
if (paletteDirty) {
const QString scheme = resolvedScheme();
PaletteConfig cfg =
PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme);
if (!ThemeManager::commitPalette(writableThemeDir(), scheme, cfg)) {
QMessageBox::warning(this, tr("Save failed"),
tr("Could not write the theme palette to:\n%1").arg(writableThemeDir()));
return false;
}
themeManager->reloadCurrentTheme();
}
return true;
}
QString ThemeSetupPage::writableThemeDir() const
{
// Built-in themes resolve to the read-only system themes directory;
// palette edits must go to the user themes directory instead, exactly
// as PaletteEditorDialog does.
const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
if (!dirPath.isEmpty()) {
const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test");
QFile f(probe);
if (f.open(QIODevice::WriteOnly)) {
f.close();
f.remove();
return dirPath;
}
}
return QDir(SettingsCache::instance().paths().getThemesPath())
.absoluteFilePath(SettingsCache::instance().getThemeName());
}
bool ThemeSetupPage::isSkippable() const
{
return true;
}
QString ThemeSetupPage::stepTitle() const
{
return tr("Pick a Look");
}
QString ThemeSetupPage::stepSubtitle() const
{
return tr("You can fine-tune every colour later from Settings → Appearance.");
}
void ThemeSetupPage::retranslateUi()
{
accentGroup->setTitle(tr("Accent colour (optional)"));
}

View file

@ -0,0 +1,58 @@
#ifndef THEME_SETUP_PAGE_H
#define THEME_SETUP_PAGE_H
#include "../first_run_wizard_page.h"
class QComboBox;
class QGroupBox;
class QuickSetupPanel;
/** @brief First-run theme step. Reuses the same building blocks as Appearance
* settings and the Palette Editor (ThemeManager, PaletteConfig,
* PaletteGenerator, and the QuickSetupPanel widget itself) rather than
* reimplementing palette generation or preview here.
*
* Behavior specific to this page (deliberately not pushed down into
* ThemeManager, to avoid changing app-wide behaviour for existing installs):
* - Opening the page never changes the running palette; previews and
* auto-generation only happen when the user actually changes a control.
* - If a theme+scheme the user selects has no saved palette and no shipped
* default, one is generated from the QuickSetupPanel's current accent so
* the preview doesn't fall back to a flat, unstyled look. */
class ThemeSetupPage : public FirstRunWizardPage
{
Q_OBJECT
public:
explicit ThemeSetupPage(QWidget *parent = nullptr);
void initializePage() override;
bool validatePage() override;
bool isSkippable() const override;
QString stepTitle() const override;
QString stepSubtitle() const override;
void retranslateUi() override;
private slots:
void onThemeChanged(int index);
void onSchemeChanged();
void onGenerateFromAccent(const QColor &accent, int intensity);
void onHomeTabBackgroundChanged(int index);
private:
QString currentScheme() const;
QString resolvedScheme() const; // "System" -> actual Light/Dark
void maybeAutoGeneratePalette();
QString writableThemeDir() const;
QComboBox *themeCombo;
QComboBox *schemeCombo;
QGroupBox *accentGroup;
QuickSetupPanel *quickSetupPanel;
QComboBox *homeTabBackgroundCombo;
bool paletteDirty = false;
};
#endif // THEME_SETUP_PAGE_H

View file

@ -0,0 +1,79 @@
#include "welcome_page.h"
#include "../../../../main.h"
#include "../../client/settings/cache_settings.h"
#include "../../settings_page/general_settings_page.h"
#include "libcockatrice/settings/personal_settings.h"
#include <QApplication>
#include <QComboBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QLocale>
#include <QTranslator>
#include <QVBoxLayout>
WelcomePage::WelcomePage(QWidget *parent) : FirstRunWizardPage(parent)
{
bodyLabel = new QLabel(this);
bodyLabel->setWordWrap(true);
bodyLabel->setAlignment(Qt::AlignCenter);
languageLabel = new QLabel(this);
langCombo = new QComboBox(this);
for (const QString &code : GeneralSettingsPage::findQmFiles()) {
langCombo->addItem(GeneralSettingsPage::languageName(code), code);
}
QString current = SettingsCache::instance().personal().getLang();
if (current.isEmpty()) {
current = QLocale::system().name();
}
int index = langCombo->findData(current);
if (index < 0) {
index = langCombo->findData(current.section('_', 0, 0));
}
if (index >= 0) {
langCombo->setCurrentIndex(index);
}
connect(langCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &WelcomePage::languageChanged);
auto *languageRow = new QHBoxLayout;
languageRow->addStretch();
languageRow->addWidget(languageLabel);
languageRow->addWidget(langCombo);
languageRow->addStretch();
auto *layout = new QVBoxLayout(this);
layout->addStretch();
layout->addWidget(bodyLabel);
layout->addStretch();
layout->addLayout(languageRow);
retranslateUi();
}
void WelcomePage::languageChanged(int index)
{
if (index < 0) {
return;
}
SettingsCache::instance().personal().setLang(langCombo->itemData(index).toString());
qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast)
installNewTranslator();
}
QString WelcomePage::stepTitle() const
{
return tr("Welcome!");
}
void WelcomePage::retranslateUi()
{
bodyLabel->setText(tr("Let's get you set up. This will only take a minute — "
"we'll grab the card database, pick a look you like, "
"and get you ready to connect to a server.\n\n"
"You can change any of this later from Settings."));
languageLabel->setText(tr("Language:"));
}

View file

@ -0,0 +1,28 @@
#ifndef WELCOME_PAGE_H
#define WELCOME_PAGE_H
#include "../first_run_wizard_page.h"
class QComboBox;
class QLabel;
class WelcomePage : public FirstRunWizardPage
{
Q_OBJECT
public:
explicit WelcomePage(QWidget *parent = nullptr);
QString stepTitle() const override;
void retranslateUi() override;
private slots:
void languageChanged(int index);
private:
QLabel *bodyLabel;
QLabel *languageLabel;
QComboBox *langCombo;
};
#endif // WELCOME_PAGE_H

View file

@ -0,0 +1,62 @@
import QtQuick
Item {
id: root
ShaderEffect {
id: effectA
anchors.fill: parent
opacity: bannerConfig.frontIsA ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutCubic } }
property real iTime: bannerConfig.time
property real uAspect: bannerConfig.aspect
property real uMode: bannerConfig.modeA
property real uSpeed: bannerConfig.speedA
property real uSeed: bannerConfig.seedA
property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0)
property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0)
property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0)
property real uLogoGlow: bannerConfig.logoGlow
fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb"
}
ShaderEffect {
id: effectB
anchors.fill: parent
opacity: bannerConfig.frontIsA ? 0.0 : 1.0
Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutCubic } }
property real iTime: bannerConfig.time
property real uAspect: bannerConfig.aspect
property real uMode: bannerConfig.modeB
property real uSpeed: bannerConfig.speedB
property real uSeed: bannerConfig.seedB
property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0)
property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0)
property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0)
property real uLogoGlow: bannerConfig.logoGlow
fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb"
}
// The hero logo itself — breathes cleanly over a 0.5–1.0 opacity range
Image {
id: logo
anchors.centerIn: parent
visible: bannerConfig.logoVisible
source: "qrc:/resources/cockatrice-logo-white.svg"
width: root.height * 0.6
height: width * (sourceSize.height > 0 ? sourceSize.height / Math.max(sourceSize.width, 1) : 1)
fillMode: Image.PreserveAspectFit
smooth: true
opacity: 0.5 + 0.5 * bannerConfig.logoGlow
sourceSize: Qt.size(256, 256)
Behavior on opacity { NumberAnimation { duration: 300; easing.type: Easing.InOutSine } }
transform: Scale {
origin.x: logo.width / 2
origin.y: logo.height / 2
xScale: 0.94 + 0.06 * bannerConfig.logoGlow
yScale: 0.94 + 0.06 * bannerConfig.logoGlow
}
}
}

View file

@ -0,0 +1,195 @@
#include "shader_banner_widget.h"
#include "banner_shader_config.h"
#include <QPainter>
#include <QQmlContext>
#include <QQmlEngine>
#include <QQuickWidget>
#include <QResizeEvent>
#include <QStackedLayout>
namespace
{
// Near-black base palette -- the background is dark and quiet so the green
// accent stands out.
constexpr QRgb kColorA = 0x1A1A20;
constexpr QRgb kColorB = 0x0E0E12;
constexpr QRgb kAccent = 0x8BDD6B;
} // namespace
class GradientFallbackWidget : public QWidget
{
public:
using QWidget::QWidget;
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
QLinearGradient gradient(0, 0, width(), height());
gradient.setColorAt(0.0, QColor(kColorA));
gradient.setColorAt(1.0, QColor(kColorB));
painter.fillRect(rect(), gradient);
}
};
BannerHost::BannerHost(QWidget *parent) : QWidget(parent)
{
setFixedHeight(150);
stack = new QStackedLayout(this);
stack->setContentsMargins(0, 0, 0, 0);
fallback = new GradientFallbackWidget(this);
stack->addWidget(fallback);
quickWidget = new QQuickWidget(this);
quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView);
config = new BannerShaderConfig(quickWidget->engine());
quickWidget->rootContext()->setContextProperty("bannerConfig", config);
quickWidget->setSource(QUrl("qrc:/onboarding/qml/BrandBanner.qml"));
if (quickWidget->status() == QQuickWidget::Error) {
activateFallback();
} else {
connect(quickWidget, &QQuickWidget::sceneGraphError, this, &BannerHost::onSceneGraphFailed);
stack->addWidget(quickWidget);
stack->setCurrentWidget(quickWidget);
}
connect(&clock, &QTimer::timeout, this, &BannerHost::tick);
clock.setInterval(16); // ~60fps; the shader itself is cheap, this is just a wall clock
applyMotifPreset(currentMotif);
updateAspect();
}
void BannerHost::activateFallback()
{
if (usingFallback) {
return;
}
usingFallback = true;
clock.stop();
stack->setCurrentWidget(fallback);
if (quickWidget) {
quickWidget->deleteLater(); // takes BannerShaderConfig (parented to its engine) with it
quickWidget = nullptr;
config = nullptr;
}
}
void BannerHost::onSceneGraphFailed()
{
activateFallback();
}
void BannerHost::setMotif(Motif motif)
{
currentMotif = motif;
applyMotifPreset(motif);
}
BannerHost::Preset BannerHost::presetFor(Motif motif)
{
// speed/seed tuned per motif so e.g. the network "pulse" (Account) reads
// at a deliberately calmer cadence than the data "scan" lines
// (Preferences), even though both come from the same shader.
switch (motif) {
case Motif::Welcome:
return {0.0, 0.6, 0.15};
case Motif::CardDatabase:
return {1.0, 1.3, 0.42};
case Motif::Theming:
return {2.0, 1.2, 0.73};
case Motif::Account:
return {3.0, 0.8, 0.28};
case Motif::Preferences:
return {4.0, 1.0, 0.61};
case Motif::Finish:
return {5.0, 1.0, 0.91};
}
return {0.0, 0.6, 0.15};
}
void BannerHost::applyMotifPreset(Motif motif)
{
if (usingFallback || !config) {
return;
}
const Preset p = presetFor(motif);
config->setColorA(QColor(kColorA));
config->setColorB(QColor(kColorB));
config->setAccent(QColor(kAccent));
config->setLogoVisible(motif == Motif::Welcome);
if (isFirstApply) {
// Nothing on screen yet -- write straight into the front bank, no
// crossfade needed for the very first paint.
config->setModeA(p.mode);
config->setSpeedA(p.speed);
config->setSeedA(p.seed);
config->setFrontIsA(true);
isFirstApply = false;
return;
}
// Write the new preset into whichever bank is currently hidden, then
// flip which one is front. QML's opacity Behavior does the actual
// crossfade -- BannerHost never animates anything itself.
if (config->frontIsA()) {
config->setModeB(p.mode);
config->setSpeedB(p.speed);
config->setSeedB(p.seed);
config->setFrontIsA(false);
} else {
config->setModeA(p.mode);
config->setSpeedA(p.speed);
config->setSeedA(p.seed);
config->setFrontIsA(true);
}
}
void BannerHost::updateAspect()
{
if (config && height() > 0) {
config->setAspect(qreal(width()) / qreal(height()));
}
}
void BannerHost::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
updateAspect();
}
void BannerHost::showEvent(QShowEvent *event)
{
QWidget::showEvent(event);
if (!usingFallback) {
elapsed.restart();
clock.start();
}
}
void BannerHost::hideEvent(QHideEvent *event)
{
QWidget::hideEvent(event);
clock.stop();
}
void BannerHost::tick()
{
if (config) {
qreal t = elapsed.elapsed() / 1000.0;
config->setTime(t);
// Visible breathing for the logo: oscillates between 0.0 and 1.0
qreal glow = 0.5 + 0.5 * qSin(t * 0.4);
config->setLogoGlow(glow);
}
}

View file

@ -0,0 +1,83 @@
#ifndef SHADER_BANNER_WIDGET_H
#define SHADER_BANNER_WIDGET_H
#include <QElapsedTimer>
#include <QTimer>
#include <QWidget>
class BannerShaderConfig;
class QQuickWidget;
class GradientFallbackWidget;
class QStackedLayout;
/** @brief Onboarding banner: a subtle, looping brand-shader animation, one of six
* per-page "motifs" driving the same prebaked fragment shader
* (onboarding/shaders/brand_banner.frag) with different uniform values, so
* every page feels distinct but unmistakably part of the same family.
*
* Motif switches crossfade smoothly (see BrandBanner.qml's two stacked
* ShaderEffect layers + Behavior on opacity) rather than cutting instantly
* -- BannerHost just writes the new preset into whichever layer is
* currently hidden and flips BannerShaderConfig::frontIsA; QML handles the
* actual animation declaratively.
*
* Falls back to a static two-stop gradient (no shader, no QQuickWidget) if
* the platform's Qt Quick scenegraph can't initialize -- e.g. software
* rendering only, or a CI/VM environment with no GPU -- so onboarding
* never blocks or blanks out over a graphics driver problem. The fallback
* is permanent for the lifetime of this widget once triggered. */
class BannerHost : public QWidget
{
Q_OBJECT
public:
enum class Motif
{
Welcome,
CardDatabase,
Theming,
Account,
Preferences,
Finish,
};
explicit BannerHost(QWidget *parent = nullptr);
void setMotif(Motif motif);
protected:
void showEvent(QShowEvent *event) override;
void hideEvent(QHideEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
private slots:
void tick();
void onSceneGraphFailed();
private:
struct Preset
{
qreal mode;
qreal speed;
qreal seed;
};
static Preset presetFor(Motif motif);
void applyMotifPreset(Motif motif);
void updateAspect();
void activateFallback();
QStackedLayout *stack;
QQuickWidget *quickWidget = nullptr;
BannerShaderConfig *config = nullptr;
GradientFallbackWidget *fallback = nullptr;
QTimer clock;
QElapsedTimer elapsed;
Motif currentMotif = Motif::Welcome;
bool usingFallback = false;
bool isFirstApply = true;
};
#endif // SHADER_BANNER_WIDGET_H

View file

@ -0,0 +1,461 @@
#version 440
// ════════════════════════════════════════════════════════════════════════
// brand_banner.frag
//
// One shader, six motifs (uMode 0..5). All motifs composite over a shared
// backgroundField() whose colour is flow-noise-modulated blend of uColorA
// and uColorB. SDFs operate in aspect-corrected space (ac.x = uv.x *
// uAspect) to preserve shape proportions on the wide banner.
//
// IMPORTANT: the uniform block below must list custom uniforms in EXACTLY
// the order they're declared as properties on each ShaderEffect instance in
// BrandBanner.qml (after the two Qt-supplied members, qt_Matrix/qt_Opacity).
// ════════════════════════════════════════════════════════════════════════
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf
{
mat4 qt_Matrix;
float qt_Opacity;
float iTime;
float uAspect;
float uMode;
float uSpeed;
float uSeed;
vec4 uColorA;
vec4 uColorB;
vec4 uAccent;
float uLogoGlow;
};
// ── Primitives ──────────────────────────────────────────────────────────
float hash21(vec2 p)
{
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float valueNoise(vec2 p)
{
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p)
{
float v = 0.0;
float amp = 0.5;
for (int i = 0; i < 3; i++) {
v += amp * valueNoise(p);
p *= 2.03;
amp *= 0.5;
}
return v;
}
float flowNoise(vec2 p, float t)
{
vec2 warp1 = vec2(fbm(p + vec2(0.0, 0.0)), fbm(p + vec2(5.2, 1.3)));
vec2 warp2 = vec2(fbm(p + 4.0 * warp1 + vec2(1.7, 9.2) + t * 0.6),
fbm(p + 4.0 * warp1 + vec2(8.3, 2.8) - t * 0.5));
return fbm(p + 4.0 * warp2 + t * 0.15);
}
float bloom(float d, float coreRadius, float haloRadius)
{
float core = exp(-(d * d) / (coreRadius * coreRadius));
float halo = exp(-d / haloRadius) * 0.35;
return core + halo;
}
float roundedBoxSDF(vec2 p, vec2 halfSize, float radius)
{
vec2 d = abs(p) - halfSize + radius;
return length(max(d, 0.0)) - radius + min(max(d.x, d.y), 0.0);
}
// Rotated box SDF -- applies 2D rotation to p before evaluating roundedBoxSDF.
float rotatedBoxSDF(vec2 p, vec2 halfSize, float radius, float angle)
{
float c = cos(angle);
float s = sin(angle);
vec2 rp = vec2(p.x * c - p.y * s, p.x * s + p.y * c);
return roundedBoxSDF(rp, halfSize, radius);
}
float vignette(vec2 uv)
{
vec2 c = uv - 0.5;
c.x *= max(uAspect, 0.0001);
return smoothstep(1.0, 0.25, length(c));
}
// ── Shared background ───────────────────────────────────────────────────
vec3 backgroundField(vec2 uv, float time)
{
// Diagonal luminance gradient from (0,0) to (1,1) used as blend factor
// between uColorA and uColorB; modulated by flowNoise.
float baseD = smoothstep(0.0, 1.0, uv.y * 0.5 + uv.x * 0.2);
float painted = flowNoise(uv * 1.5, time * 0.04) - 0.5;
baseD = clamp(baseD + painted * 0.12, 0.0, 1.0);
vec3 col = mix(uColorA.rgb, uColorB.rgb, baseD);
// Low-frequency fBM noise pushes local colour toward uColorB for depth
float deep = fbm(uv * 1.0 + vec2(37.1, 12.4) + time * 0.015);
col = mix(col, uColorB.rgb, (deep - 0.5) * 0.08);
// Accent-coloured fog layer: flowNoise peaks above 0.6 contribute accent
float fog = flowNoise(uv * 0.8 + vec2(100.0, 50.0), time * 0.02);
col += uAccent.rgb * max(fog - 0.6, 0.0) * 0.10;
return col;
}
// ── Motifs ──────────────────────────────────────────────────────────────
// Centre bloom, flow-noise shimmer gated to centre, and 48 orbiting ember
// particles that deflect into a tight ring near the centre.
vec3 motifWelcome(vec2 uv, vec3 bg, float t)
{
vec3 col = bg;
float asp = max(uAspect, 0.001);
vec2 ac = vec2(uv.x * asp, uv.y);
vec2 center = vec2(asp * 0.5, 0.5);
float cDist = length(ac - center);
// Centre bloom at logo position; intensity scales with uLogoGlow
float centreLight = bloom(cDist, 0.08 * asp, 0.40 * asp);
col += centreLight * 0.20 * uLogoGlow;
// Flow-noise shimmer gated by Gaussian mask at centre
float shimmer = flowNoise(ac * 0.8 + vec2(55.0, 33.0), t * 0.05) * 0.5 + 0.5;
float shimmerMask = exp(-(cDist * cDist) / (0.18 * asp * 0.18 * asp));
col += shimmer * shimmerMask * 0.04 * uLogoGlow;
// 48 ember particles: hash-seeded position, speed, size, brightness.
// Embers within a distance threshold of centre are deflected into an
// orbital ring via tangent displacement perpendicular to the centre vector.
const int EMBERS = 48;
for (int i = 0; i < EMBERS; i++) {
float fi = float(i);
float baseX = hash21(vec2(fi * 7.31 + uSeed, fi * 3.17));
float baseY = hash21(vec2(fi * 11.9 + uSeed * 1.4, fi * 5.53));
float riseSpeed = 0.025 + hash21(vec2(fi * 1.7, uSeed * 2.1)) * 0.035;
float driftAmp = 0.04 + hash21(vec2(fi * 9.3, uSeed)) * 0.06;
float driftFreq = 0.3 + hash21(vec2(fi * 4.1, uSeed * 3.3)) * 0.5;
float pX = baseX * asp + sin(t * driftFreq + fi * 1.7) * driftAmp * asp;
float pY = fract(baseY + t * riseSpeed);
float size = 0.006 + hash21(vec2(fi * 2.9, uSeed * 4.7)) * 0.012;
float bright = 0.15 + hash21(vec2(fi * 6.1, uSeed * 0.9)) * 0.30;
// Fade out near top/bottom edges
float edgeFade = smoothstep(0.0, 0.12, pY) * smoothstep(1.0, 0.88, pY);
float twinkle = 0.6 + 0.4 * sin(t * (1.2 + fi * 0.37) + fi * 2.9);
vec2 ePos = vec2(pX, pY);
// Embers near centre: deflect into orbital ring via tangent displacement
vec2 toCenter = ePos - center;
float distToCenter = length(toCenter);
float ringWeight = smoothstep(0.38 * asp, 0.06 * asp, distToCenter);
float orbitPhase = t * (0.15 + fi * 0.020) + fi * 2.3;
float orbitAmount = 0.020 + hash21(vec2(fi * 12.3, uSeed * 2.7)) * 0.020;
vec2 tangent = vec2(-toCenter.y, toCenter.x);
vec2 deflected = ePos + tangent * ringWeight * orbitAmount * asp * sin(orbitPhase);
float pushOut = ringWeight * (0.008 + hash21(vec2(fi * 6.7, uSeed * 1.1)) * 0.012) * asp;
deflected += normalize(toCenter + 0.001) * pushOut;
float dist = length(ac - deflected);
float intensity = bright * edgeFade * twinkle;
col += uAccent.rgb * bloom(dist, size, size * 4.0) * intensity;
}
return col;
}
// 25 card-shaped box SDFs at parallax depths drifting horizontally across
// the banner; each card has a semi-transparent fill, accent outline, and
// card-back diamond pattern.
vec3 motifCardDatabase(vec2 uv, vec3 bg, float t)
{
vec3 col = bg;
float asp = max(uAspect, 0.001);
vec2 ac = vec2(uv.x * asp, uv.y);
const int CARDS = 25;
for (int i = 0; i < CARDS; i++) {
float fi = float(i);
// Parallax depth via hash; used to scale size, speed, brightness
float depth = hash21(vec2(fi * 1.37 + uSeed, fi * 0.91));
// Card dimensions in corrected space (portrait: height > width)
float cardH = mix(0.055, 0.15, depth);
cardH *= 0.85 + 0.30 * hash21(vec2(fi * 3.14, uSeed * 2.71));
float cardW = cardH * 0.71; // 5:7 ratio
// Horizontal drift; nearer cards (higher depth) move faster
float speed = mix(0.06, 0.18, depth);
float xPhase = hash21(vec2(fi * 7.13, uSeed * 4.37));
xPhase = fract(xPhase + t * speed);
float x = mix(-1.5, asp + 1.5, xPhase);
// Vertical position: hash distribution with sinusoidal oscillation
float yBase = hash21(vec2(fi * 2.91, uSeed * 1.63));
float y = yBase + sin(t * 0.6 + fi * 1.9) * 0.035;
y = clamp(y, cardH + 0.02, 1.0 - cardH - 0.02);
// Random rotation angle ±4 degrees
float tilt = (hash21(vec2(fi * 5.71, uSeed * 8.29)) - 0.5) * 0.14;
vec2 p = ac - vec2(x, y);
float d = rotatedBoxSDF(p, vec2(cardW, cardH), cardW * 0.14, tilt);
// Semi-transparent dark fill
float fill = smoothstep(0.015, -0.005, d);
col = mix(col, uColorB.rgb * 0.55, fill * 0.50);
// Accent outline
float edge = smoothstep(0.035, 0.0, abs(d));
col += uAccent.rgb * edge * mix(0.18, 0.50, 1.0 - depth);
// Card-back diamond: smaller rotated box inset from card edges
float innerD = rotatedBoxSDF(p, vec2(cardW * 0.45, cardH * 0.55), cardW * 0.08, tilt);
float innerEdge = smoothstep(0.012, 0.0, abs(innerD));
col += uAccent.rgb * innerEdge * fill * 0.12 * (1.0 - depth);
// Centre dot
float dotDist = length(p);
col += uAccent.rgb * bloom(dotDist, 0.008, 0.02) * fill * 0.15 * (1.0 - depth);
}
return col;
}
// 4 horizontal bands with multi-frequency sinusoidal warp and pulsing width.
vec3 motifTheming(vec2 uv, vec3 bg, float t)
{
vec3 col = bg;
const int BANDS = 4;
for (int i = 0; i < BANDS; i++) {
float fi = float(i);
float yCenter = 0.18 + fi * 0.22;
// Three summed sinusoids for horizontal undulation
float wave = sin(uv.x * 3.2 + t * 0.5 + fi * 2.1) * 0.08;
wave += sin(uv.x * 7.0 - t * 0.3 + fi * 1.3) * 0.035;
wave += sin(uv.x * 1.6 + t * 0.18 + fi * 3.7) * 0.05;
float bandDist = abs(uv.y - yCenter - wave);
float bandWidth = 0.04 + sin(t * 0.2 + fi * 0.8) * 0.012;
float band = smoothstep(bandWidth, 0.0, bandDist);
// Upper bands have higher intensity
float intensity = mix(0.15, 0.38, 1.0 - fi / float(BANDS));
col += uAccent.rgb * band * intensity;
}
return col;
}
// 14 nodes at pseudo-random positions with sinusoidal pulse; edges drawn
// between nodes within a threshold distance; central glow + periodic ring.
vec3 motifAccount(vec2 uv, vec3 bg, float t)
{
vec3 col = bg;
float asp = max(uAspect, 0.001);
vec2 ac = vec2(uv.x * asp, uv.y);
vec2 center = vec2(asp * 0.5, 0.5);
const int NODES = 14;
vec2 nodePos[14];
float nodePulse[14];
for (int i = 0; i < NODES; i++) {
float fi = float(i);
// Hash-seeded position with gentle sinusoidal drift
float nx = hash21(vec2(fi * 3.17 + uSeed, fi * 1.93)) * asp;
float ny = hash21(vec2(fi * 5.41 + uSeed * 1.7, fi * 2.79));
float dx = sin(t * 0.12 + fi * 1.7) * 0.08;
float dy = cos(t * 0.09 + fi * 2.3) * 0.04;
vec2 pos = vec2(nx + dx, ny + dy);
nodePos[i] = pos;
// Per-node pulse phase, normalised to [0, 1]
float pulsePhase = hash21(vec2(fi * 4.31, uSeed * 6.17));
float pulse = sin(t * 0.8 + pulsePhase * 6.283) * 0.5 + 0.5;
nodePulse[i] = pulse;
// Node glow via bloom; intensity modulated by pulse
float dist = length(ac - pos);
col += uAccent.rgb * bloom(dist, 0.018, 0.08) * mix(0.20, 0.45, pulse);
}
// Edges: connect nodes within a radius threshold
float connectDist = asp * 0.22;
for (int i = 0; i < NODES; i++) {
for (int j = i + 1; j < NODES; j++) {
float pairDist = length(nodePos[i] - nodePos[j]);
if (pairDist < connectDist) {
float strength = 1.0 - pairDist / connectDist;
vec2 pa = ac - nodePos[i];
vec2 ba = nodePos[j] - nodePos[i];
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
float lineDist = length(pa - ba * h);
col += uAccent.rgb * smoothstep(0.010, 0.0, lineDist) * strength * 0.10;
}
}
}
// Central bloom at banner centre
float cDist = length(ac - center);
col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.12;
// Periodic expanding ring from centre
float ripplePhase = t * 0.4;
float rippleDist = abs(cDist - fract(ripplePhase) * asp * 0.7);
col += uAccent.rgb * smoothstep(0.02, 0.0, rippleDist) * 0.10;
return col;
}
// 18x5 toggle-grid of rounded boxes with hash-driven on/off per cell;
// a scanning highlight sweeps L-to-R, brightening cells near the scan line.
vec3 motifPreferences(vec2 uv, vec3 bg, float t)
{
vec3 col = bg;
float cols = 18.0;
float rows = 5.0;
vec2 gridUV = uv * vec2(cols, rows);
vec2 cell = fract(gridUV) - 0.5;
vec2 cellId = floor(gridUV);
// On/off state per cell, hash-seeded for pseudo-randomness
float on = step(0.55, hash21(cellId + uSeed * 10.0));
float d = roundedBoxSDF(cell, vec2(0.28, 0.32), 0.06);
// Filled "on" cells
float cellFill = smoothstep(0.04, -0.02, d);
col += uAccent.rgb * cellFill * on * 0.18;
// Cell borders (drawn on all cells)
float border = smoothstep(0.025, 0.0, abs(d));
col += uAccent.rgb * border * 0.06;
// Scanning highlight: thin line + soft glow sweeping L-to-R
float scanX = fract(t * 0.15);
float scanDist = abs(uv.x - scanX);
float scanLine = smoothstep(0.015, 0.0, scanDist);
col += uAccent.rgb * scanLine * 0.40;
float scanGlow = smoothstep(0.08, 0.0, scanDist);
col += uAccent.rgb * scanGlow * 0.08;
// "On" cells near the scan line get extra brightness
float scanProximity = smoothstep(0.12, 0.0, scanDist);
col += uAccent.rgb * cellFill * on * scanProximity * 0.15;
return col;
}
// Centre radial bloom with sinusoidal pulse, 4 expanding ring halos with
// outer glow falloff, and 35 rising particles.
vec3 motifFinish(vec2 uv, vec3 bg, float t)
{
vec3 col = bg;
float asp = max(uAspect, 0.001);
vec2 ac = vec2(uv.x * asp, uv.y);
vec2 center = vec2(asp * 0.5, 0.5);
float cDist = length(ac - center);
// Centre bloom with sinusoidal pulse modulation
float pulse = 0.65 + 0.35 * sin(t * 0.4);
col += uAccent.rgb * bloom(cDist, 0.12, 0.55) * 0.10 * pulse;
// 4 expanding rings: radius increases via phase; ring width grows with
// expansion; combined with exponential outer glow falloff
for (int i = 0; i < 4; i++) {
float fi = float(i);
float phase = fract(t * 0.06 + fi * 0.25);
float ringRadius = phase * asp * 0.7;
float ringDist = abs(cDist - ringRadius);
float ringWidth = 0.025 + phase * 0.025;
float ring = smoothstep(ringWidth, 0.0, ringDist);
float outerGlow = exp(-ringDist / (0.03 + phase * 0.02)) * 0.3;
float combined = ring + outerGlow;
float fade = 1.0 - phase * 0.5;
col += uAccent.rgb * combined * fade * 0.15;
}
// 35 particles rising vertically with sinusoidal horizontal drift;
// each particle uses bloom with edge fade and twinkle animation
const int PARTICLES = 35;
for (int i = 0; i < PARTICLES; i++) {
float fi = float(i);
float baseX = hash21(vec2(fi * 13.7 + uSeed, fi * 7.31));
float baseY = hash21(vec2(fi * 23.1 + uSeed * 1.9, fi * 11.3));
float riseSpeed = 0.04 + hash21(vec2(fi * 3.1, uSeed * 2.7)) * 0.06;
float driftAmp = 0.03 + hash21(vec2(fi * 8.9, uSeed)) * 0.05;
float driftFreq = 0.4 + hash21(vec2(fi * 5.3, uSeed * 4.1)) * 0.6;
float pX = baseX * asp + sin(t * driftFreq + fi * 2.3) * driftAmp * asp;
float pY = fract(baseY + t * riseSpeed);
float size = 0.005 + hash21(vec2(fi * 4.7, uSeed * 3.9)) * 0.010;
float bright = 0.12 + hash21(vec2(fi * 7.1, uSeed * 1.3)) * 0.25;
float edgeFade = smoothstep(0.0, 0.1, pY) * smoothstep(1.0, 0.9, pY);
float twinkle = 0.5 + 0.5 * sin(t * (1.8 + fi * 0.43) + fi * 3.1);
vec2 pPos = vec2(pX, pY);
float dist = length(ac - pPos);
col += uAccent.rgb * bloom(dist, size, size * 3.5) * bright * edgeFade * twinkle;
}
return col;
}
// ── Main ────────────────────────────────────────────────────────────────
void main()
{
vec2 uv = qt_TexCoord0;
float t = iTime * uSpeed;
vec3 bg = backgroundField(uv, iTime);
vec3 col;
if (uMode < 0.5) col = motifWelcome(uv, bg, t);
else if (uMode < 1.5) col = motifCardDatabase(uv, bg, t);
else if (uMode < 2.5) col = motifTheming(uv, bg, t);
else if (uMode < 3.5) col = motifAccount(uv, bg, t);
else if (uMode < 4.5) col = motifPreferences(uv, bg, t);
else col = motifFinish(uv, bg, t);
col *= mix(0.62, 1.0, vignette(uv));
fragColor = vec4(col, 1.0) * qt_Opacity;
}

View file

@ -0,0 +1,84 @@
#include "step_indicator_widget.h"
#include <QPainter>
#include <QPainterPath>
StepIndicatorWidget::StepIndicatorWidget(QWidget *parent) : QWidget(parent)
{
setFixedHeight(kDotDiameter + 2 * kVerticalMargin);
}
void StepIndicatorWidget::setStepCount(int count)
{
stepCount = qMax(0, count);
currentStep = qBound(0, currentStep, qMax(0, stepCount - 1));
updateGeometry();
update();
}
void StepIndicatorWidget::setCurrentStep(int index)
{
if (stepCount == 0) {
return;
}
currentStep = qBound(0, index, stepCount - 1);
update();
}
QSize StepIndicatorWidget::sizeHint() const
{
return minimumSizeHint();
}
QSize StepIndicatorWidget::minimumSizeHint() const
{
if (stepCount == 0) {
return QSize(0, height());
}
int width = kActiveDotWidth + (stepCount - 1) * kDotDiameter + (stepCount - 1) * kDotSpacing;
return QSize(width, height());
}
void StepIndicatorWidget::paintEvent(QPaintEvent * /*event*/)
{
if (stepCount == 0) {
return;
}
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const QColor activeColor = palette().color(QPalette::Highlight);
// QPalette::Mid alpha-blended against a dark Window background reads as
// near-invisible (Mid is itself a dark grey in dark palettes -- see
// PaletteGenerator's satShadeLo/Dark roles). WindowText is guaranteed to
// contrast against Window in any theme by definition, so alpha-blending
// *that* instead keeps the dots visibly dim-but-present in both light and
// dark schemes. Same trick PaletteGenerator uses for placeholder text.
QColor inactiveColor = palette().color(QPalette::WindowText);
inactiveColor.setAlpha(100);
int totalWidth = 0;
for (int i = 0; i < stepCount; ++i) {
totalWidth += (i == currentStep) ? kActiveDotWidth : kDotDiameter;
if (i > 0) {
totalWidth += kDotSpacing;
}
}
int x = (width() - totalWidth) / 2;
const int y = height() / 2;
for (int i = 0; i < stepCount; ++i) {
const bool active = (i == currentStep);
const int dotWidth = active ? kActiveDotWidth : kDotDiameter;
QPainterPath path;
QRectF rect(x, y - kDotDiameter / 2.0, dotWidth, kDotDiameter);
path.addRoundedRect(rect, kDotDiameter / 2.0, kDotDiameter / 2.0);
painter.fillPath(path, active ? activeColor : inactiveColor);
x += dotWidth + kDotSpacing;
}
}

View file

@ -0,0 +1,34 @@
#ifndef STEP_INDICATOR_WIDGET_H
#define STEP_INDICATOR_WIDGET_H
#include <QWidget>
/** @brief Row of dots showing progress through a fixed-length sequence of steps,
* in the style of a mobile/OS setup flow. Purely presentational. */
class StepIndicatorWidget : public QWidget
{
Q_OBJECT
public:
explicit StepIndicatorWidget(QWidget *parent = nullptr);
void setStepCount(int count);
void setCurrentStep(int index);
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
protected:
void paintEvent(QPaintEvent *event) override;
private:
int stepCount = 0;
int currentStep = 0;
static constexpr int kDotDiameter = 8;
static constexpr int kActiveDotWidth = 22;
static constexpr int kDotSpacing = 10;
static constexpr int kVerticalMargin = 6;
};
#endif // STEP_INDICATOR_WIDGET_H

View file

@ -1,6 +1,7 @@
#include "printing_selector_card_overlay_widget.h"
#include "../../../client/settings/cache_settings.h"
#include "../cards/card_info_picture_widget.h"
#include "printing_selector_card_display_widget.h"
#include <QImageReader>

View file

@ -7,9 +7,9 @@
#include "../chat_view/chat_view.h"
#include "../game_selector.h"
#include "user_info_box.h"
#include "user_list_dialog.h"
#include "user_list_manager.h"
#include "user_list_proxy.h"
#include "user_list_widget.h"
#include <QAction>
#include <QMenu>

View file

@ -525,6 +525,13 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); });
add(games);
// ── Invite (only while the inviter has a joinable game for this user) ────
if (!isSelf && online && gameInviteAvailable && gameInviteAvailable(name)) {
auto *invite = makeBtn(tr("Invite"), tr("Invite to your game"), actionArea, theme);
connect(invite, &QPushButton::clicked, this, [this, name] { emit inviteRequested(name); });
add(invite);
}
// ── Buddy / ignore (registered users only) ────────────────────────────────
if (!isSelf && isReg) {
if (isBuddy) {

View file

@ -9,6 +9,7 @@
#include <QMap>
#include <QPixmap>
#include <QStandardItemModel>
#include <functional>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
@ -149,6 +150,17 @@ public:
/** Re-pulls the avatar/card art for the currently shown user (e.g. after it loads). */
void refreshHeader();
/**
* Sets a predicate evaluated on every action-button rebuild. It receives
* the name of the user the popup currently shows; when it returns true an
* "Invite" button is shown. The popup itself never resolves the invite
* link, it just forwards the request.
*/
void setGameInviteAvailable(std::function<bool(const QString &userName)> available)
{
gameInviteAvailable = std::move(available);
}
signals:
void mouseEnteredPopup();
void mouseLeftPopup();
@ -159,6 +171,7 @@ signals:
// ── Action signals — connect to UserContextMenu::exec*() ──────────────────
void chatRequested(const QString &userName);
void inviteRequested(const QString &userName);
void detailsRequested(const QString &userName);
void showGamesRequested(const QString &userName);
void addBuddyRequested(const QString &userName);
@ -200,6 +213,7 @@ private:
QString currentUser;
ServerInfo_User currentUserInfo;
bool currentOnline = false;
std::function<bool(const QString &userName)> gameInviteAvailable;
UserInfoHeaderWidget *header;
QWidget *actionArea; ///< rebuilt per user

View file

@ -0,0 +1,302 @@
#include "user_list_dialog.h"
#include <QCheckBox>
#include <QComboBox>
#include <QGroupBox>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QRadioButton>
#include <QSpinBox>
#include <QVBoxLayout>
#include <libcockatrice/utility/string_limits.h>
BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent)
{
setAttribute(Qt::WA_DeleteOnClose);
nameBanCheckBox = new QCheckBox(tr("ban &user name"));
nameBanCheckBox->setChecked(true);
nameBanEdit = new QLineEdit(QString::fromStdString(info.name()));
nameBanEdit->setMaxLength(MAX_NAME_LENGTH);
ipBanCheckBox = new QCheckBox(tr("ban &IP address"));
ipBanCheckBox->setChecked(true);
ipBanEdit = new QLineEdit(QString::fromStdString(info.address()));
ipBanEdit->setMaxLength(MAX_NAME_LENGTH);
idBanCheckBox = new QCheckBox(tr("ban client I&D"));
idBanCheckBox->setChecked(true);
idBanEdit = new QLineEdit(QString::fromStdString(info.clientid()));
idBanEdit->setMaxLength(MAX_NAME_LENGTH);
if (QString::fromStdString(info.clientid()).isEmpty()) {
idBanCheckBox->setChecked(false);
}
QGridLayout *banTypeGrid = new QGridLayout;
banTypeGrid->addWidget(nameBanCheckBox, 0, 0);
banTypeGrid->addWidget(nameBanEdit, 0, 1);
banTypeGrid->addWidget(ipBanCheckBox, 1, 0);
banTypeGrid->addWidget(ipBanEdit, 1, 1);
banTypeGrid->addWidget(idBanCheckBox, 2, 0);
banTypeGrid->addWidget(idBanEdit, 2, 1);
QGroupBox *banTypeGroupBox = new QGroupBox(tr("Ban type"));
banTypeGroupBox->setLayout(banTypeGrid);
permanentRadio = new QRadioButton(tr("&permanent ban"));
temporaryRadio = new QRadioButton(tr("&temporary ban"));
temporaryRadio->setChecked(true);
connect(temporaryRadio, &QRadioButton::toggled, this, &BanDialog::enableTemporaryEdits);
daysLabel = new QLabel(tr("&Days:"));
daysEdit = new QSpinBox;
daysEdit->setMinimum(0);
daysEdit->setValue(0);
daysEdit->setMaximum(10000);
daysLabel->setBuddy(daysEdit);
hoursLabel = new QLabel(tr("&Hours:"));
hoursEdit = new QSpinBox;
hoursEdit->setMinimum(0);
hoursEdit->setValue(0);
hoursEdit->setMaximum(24);
hoursLabel->setBuddy(hoursEdit);
minutesLabel = new QLabel(tr("&Minutes:"));
minutesEdit = new QSpinBox;
minutesEdit->setMinimum(0);
minutesEdit->setValue(5);
minutesEdit->setMaximum(60);
minutesLabel->setBuddy(minutesEdit);
QGridLayout *durationLayout = new QGridLayout;
durationLayout->addWidget(permanentRadio, 0, 0, 1, 6);
durationLayout->addWidget(temporaryRadio, 1, 0, 1, 6);
durationLayout->addWidget(daysLabel, 2, 0);
durationLayout->addWidget(daysEdit, 2, 1);
durationLayout->addWidget(hoursLabel, 2, 2);
durationLayout->addWidget(hoursEdit, 2, 3);
durationLayout->addWidget(minutesLabel, 2, 4);
durationLayout->addWidget(minutesEdit, 2, 5);
QGroupBox *durationGroupBox = new QGroupBox(tr("Duration of the ban"));
durationGroupBox->setLayout(durationLayout);
QLabel *reasonLabel = new QLabel(tr("Please enter the reason for the ban.\n"
"This is only saved for moderators and cannot be seen by the banned person."));
reasonEdit = new QPlainTextEdit;
QLabel *visibleReasonLabel =
new QLabel(tr("Please enter the reason for the ban that will be visible to the banned person."));
visibleReasonEdit = new QPlainTextEdit;
deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms"));
QPushButton *okButton = new QPushButton(tr("&OK"));
okButton->setAutoDefault(true);
connect(okButton, &QPushButton::clicked, this, &BanDialog::okClicked);
QPushButton *cancelButton = new QPushButton(tr("&Cancel"));
connect(cancelButton, &QPushButton::clicked, this, &BanDialog::reject);
QHBoxLayout *buttonLayout = new QHBoxLayout;
buttonLayout->addStretch();
buttonLayout->addWidget(okButton);
buttonLayout->addWidget(cancelButton);
QVBoxLayout *vbox = new QVBoxLayout;
vbox->addWidget(banTypeGroupBox);
vbox->addWidget(durationGroupBox);
vbox->addWidget(reasonLabel);
vbox->addWidget(reasonEdit);
vbox->addWidget(visibleReasonLabel);
vbox->addWidget(visibleReasonEdit);
vbox->addWidget(deleteMessages);
vbox->addLayout(buttonLayout);
setLayout(vbox);
setWindowTitle(tr("Ban user from server"));
}
WarningDialog::WarningDialog(const QString &userName, const QString &clientID, QWidget *parent) : QDialog(parent)
{
setAttribute(Qt::WA_DeleteOnClose);
descriptionLabel = new QLabel(tr("Which warning would you like to send?"));
nameWarning = new QLineEdit(userName);
nameWarning->setMaxLength(MAX_NAME_LENGTH);
warnClientID = new QLineEdit(clientID);
warnClientID->setMaxLength(MAX_NAME_LENGTH);
warningOption = new QComboBox();
warningOption->addItem("", "");
deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms"));
QPushButton *okButton = new QPushButton(tr("&OK"));
okButton->setAutoDefault(true);
connect(okButton, &QPushButton::clicked, this, &WarningDialog::okClicked);
QPushButton *cancelButton = new QPushButton(tr("&Cancel"));
connect(cancelButton, &QPushButton::clicked, this, &WarningDialog::reject);
QHBoxLayout *buttonLayout = new QHBoxLayout;
buttonLayout->addStretch();
buttonLayout->addWidget(okButton);
buttonLayout->addWidget(cancelButton);
QVBoxLayout *vbox = new QVBoxLayout;
vbox->addWidget(descriptionLabel);
vbox->addWidget(nameWarning);
vbox->addWidget(warningOption);
vbox->addWidget(deleteMessages);
vbox->addLayout(buttonLayout);
setLayout(vbox);
setWindowTitle(tr("Warn user for misconduct"));
}
void WarningDialog::okClicked()
{
if (nameWarning->text().simplified().isEmpty()) {
QMessageBox::critical(this, tr("Error"),
tr("User name to send a warning to can not be blank, please specify a user to warn."));
return;
}
if (warningOption->currentData().toString().simplified().isEmpty()) {
QMessageBox::critical(this, tr("Error"),
tr("Warning to use can not be blank, please select a valid warning to send."));
return;
}
accept();
}
QString WarningDialog::getName() const
{
return nameWarning->text().simplified();
}
QString WarningDialog::getWarnID() const
{
return warnClientID->text().simplified();
}
QString WarningDialog::getReason() const
{
return warningOption->currentData().toString().simplified();
}
int WarningDialog::getDeleteMessages() const
{
return deleteMessages->isChecked() ? -1 : 0;
}
void WarningDialog::addWarningOption(const QString &warning, int startingIl)
{
if (startingIl > 1) {
warningOption->addItem(tr("%1 (IL %2)").arg(warning).arg(startingIl), warning);
} else {
warningOption->addItem(warning, warning);
}
}
void BanDialog::okClicked()
{
if (!nameBanCheckBox->isChecked() && !ipBanCheckBox->isChecked() && !idBanCheckBox->isChecked()) {
QMessageBox::critical(this, tr("Error"),
tr("You have to select a name-based, IP-based, clientId based, or some combination of "
"the three to place a ban."));
return;
}
if (nameBanCheckBox->isChecked()) {
if (nameBanEdit->text().simplified() == "") {
QMessageBox::critical(this, tr("Error"),
tr("You must have a value in the name ban when selecting the name ban checkbox."));
return;
}
}
if (ipBanCheckBox->isChecked()) {
if (ipBanEdit->text().simplified() == "") {
QMessageBox::critical(this, tr("Error"),
tr("You must have a value in the ip ban when selecting the ip ban checkbox."));
return;
}
}
if (idBanCheckBox->isChecked()) {
if (idBanEdit->text().simplified() == "") {
QMessageBox::critical(
this, tr("Error"),
tr("You must have a value in the clientid ban when selecting the clientid ban checkbox."));
return;
}
}
accept();
}
void BanDialog::enableTemporaryEdits(bool enabled)
{
daysLabel->setEnabled(enabled);
daysEdit->setEnabled(enabled);
hoursLabel->setEnabled(enabled);
hoursEdit->setEnabled(enabled);
minutesLabel->setEnabled(enabled);
minutesEdit->setEnabled(enabled);
}
QString BanDialog::getBanId() const
{
return idBanCheckBox->isChecked() ? idBanEdit->text() : QString();
}
QString BanDialog::getBanName() const
{
return nameBanCheckBox->isChecked() ? nameBanEdit->text() : QString();
}
QString BanDialog::getBanIP() const
{
return ipBanCheckBox->isChecked() ? ipBanEdit->text() : QString();
}
int BanDialog::getMinutes() const
{
return permanentRadio->isChecked() ? 0
: (daysEdit->value() * 24 * 60 + hoursEdit->value() * 60 + minutesEdit->value());
}
QString BanDialog::getReason() const
{
return reasonEdit->toPlainText();
}
QString BanDialog::getVisibleReason() const
{
return visibleReasonEdit->toPlainText();
}
int BanDialog::getDeleteMessages() const
{
return deleteMessages->isChecked() ? -1 : 0;
}
AdminNotesDialog::AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent)
: QDialog(_parent), userName(_userName)
{
setAttribute(Qt::WA_DeleteOnClose);
auto *updateButton = new QPushButton(tr("Update Notes"));
updateButton->setEnabled(false);
connect(updateButton, &QPushButton::clicked, this, &AdminNotesDialog::accept);
notes = new QPlainTextEdit(_notes);
notes->setMinimumWidth(500);
connect(notes, &QPlainTextEdit::textChanged, this, [=]() { updateButton->setEnabled(true); });
auto *vbox = new QVBoxLayout;
vbox->addWidget(notes);
vbox->addWidget(updateButton);
setLayout(vbox);
setWindowTitle(tr("Admin Notes for %1").arg(_userName));
}
QString AdminNotesDialog::getNotes() const
{
return notes->toPlainText();
}

View file

@ -0,0 +1,79 @@
#ifndef COCKATRICE_USER_LIST_DIALOG_H
#define COCKATRICE_USER_LIST_DIALOG_H
#include <QDialog>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
class QComboBox;
class QLabel;
class QPlainTextEdit;
class QRadioButton;
class QSpinBox;
class QLineEdit;
class QCheckBox;
class BanDialog : public QDialog
{
Q_OBJECT
QLabel *daysLabel, *hoursLabel, *minutesLabel;
QCheckBox *nameBanCheckBox, *ipBanCheckBox, *idBanCheckBox, *deleteMessages;
QLineEdit *nameBanEdit, *ipBanEdit, *idBanEdit;
QSpinBox *daysEdit, *hoursEdit, *minutesEdit;
QRadioButton *permanentRadio, *temporaryRadio;
QPlainTextEdit *reasonEdit, *visibleReasonEdit;
private slots:
void okClicked();
void enableTemporaryEdits(bool enabled);
public:
explicit BanDialog(const ServerInfo_User &info, QWidget *parent = nullptr);
[[nodiscard]] QString getBanName() const;
[[nodiscard]] QString getBanIP() const;
[[nodiscard]] QString getBanId() const;
[[nodiscard]] int getMinutes() const;
[[nodiscard]] QString getReason() const;
[[nodiscard]] QString getVisibleReason() const;
[[nodiscard]] int getDeleteMessages() const;
};
class WarningDialog : public QDialog
{
Q_OBJECT
QLabel *descriptionLabel;
QLineEdit *nameWarning;
QComboBox *warningOption;
QLineEdit *warnClientID;
QCheckBox *deleteMessages;
private slots:
void okClicked();
public:
WarningDialog(const QString &userName, const QString &clientID, QWidget *parent = nullptr);
[[nodiscard]] QString getName() const;
[[nodiscard]] QString getWarnID() const;
[[nodiscard]] QString getReason() const;
[[nodiscard]] int getDeleteMessages() const;
void addWarningOption(const QString &warning, int startingIl = 1);
};
class AdminNotesDialog : public QDialog
{
Q_OBJECT
QString userName;
QPlainTextEdit *notes;
public:
explicit AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent = nullptr);
[[nodiscard]] QString getName() const
{
return userName;
}
[[nodiscard]] QString getNotes() const;
};
#endif // COCKATRICE_USER_LIST_DIALOG_H

View file

@ -1,335 +1,21 @@
#include "user_list_widget.h"
#include "../../../../client/settings/cache_settings.h"
#include "../../../card_picture_loader/card_picture_loader.h"
#include "../../cards/art_crop_attribution.h"
#include "../../interface/pixel_map_generator.h"
#include "../../interface/theme_manager.h"
#include "../../interface/widgets/tabs/tab_account.h"
#include "../../interface/widgets/tabs/tab_supervisor.h"
#include "../game_selector.h"
#include "user_context_menu.h"
#include "user_list_painter.h"
#include <QApplication>
#include <QCheckBox>
#include <QCursor>
#include <QFont>
#include <QFontMetrics>
#include <QFrame>
#include <QHBoxLayout>
#include <QEvent>
#include <QHeaderView>
#include <QInputDialog>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QMenu>
#include <QMessageBox>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QRadioButton>
#include <QScreen>
#include <QSignalBlocker>
#include <QSpinBox>
#include <QWidget>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pb/response_get_user_info.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <QScrollBar>
#include <QTimer>
#include <QVBoxLayout>
#include <libcockatrice/settings/appearance_settings.h>
#include <libcockatrice/utility/string_limits.h>
BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent)
{
setAttribute(Qt::WA_DeleteOnClose);
nameBanCheckBox = new QCheckBox(tr("ban &user name"));
nameBanCheckBox->setChecked(true);
nameBanEdit = new QLineEdit(QString::fromStdString(info.name()));
nameBanEdit->setMaxLength(MAX_NAME_LENGTH);
ipBanCheckBox = new QCheckBox(tr("ban &IP address"));
ipBanCheckBox->setChecked(true);
ipBanEdit = new QLineEdit(QString::fromStdString(info.address()));
ipBanEdit->setMaxLength(MAX_NAME_LENGTH);
idBanCheckBox = new QCheckBox(tr("ban client I&D"));
idBanCheckBox->setChecked(true);
idBanEdit = new QLineEdit(QString::fromStdString(info.clientid()));
idBanEdit->setMaxLength(MAX_NAME_LENGTH);
if (QString::fromStdString(info.clientid()).isEmpty()) {
idBanCheckBox->setChecked(false);
}
QGridLayout *banTypeGrid = new QGridLayout;
banTypeGrid->addWidget(nameBanCheckBox, 0, 0);
banTypeGrid->addWidget(nameBanEdit, 0, 1);
banTypeGrid->addWidget(ipBanCheckBox, 1, 0);
banTypeGrid->addWidget(ipBanEdit, 1, 1);
banTypeGrid->addWidget(idBanCheckBox, 2, 0);
banTypeGrid->addWidget(idBanEdit, 2, 1);
QGroupBox *banTypeGroupBox = new QGroupBox(tr("Ban type"));
banTypeGroupBox->setLayout(banTypeGrid);
permanentRadio = new QRadioButton(tr("&permanent ban"));
temporaryRadio = new QRadioButton(tr("&temporary ban"));
temporaryRadio->setChecked(true);
connect(temporaryRadio, &QRadioButton::toggled, this, &BanDialog::enableTemporaryEdits);
daysLabel = new QLabel(tr("&Days:"));
daysEdit = new QSpinBox;
daysEdit->setMinimum(0);
daysEdit->setValue(0);
daysEdit->setMaximum(10000);
daysLabel->setBuddy(daysEdit);
hoursLabel = new QLabel(tr("&Hours:"));
hoursEdit = new QSpinBox;
hoursEdit->setMinimum(0);
hoursEdit->setValue(0);
hoursEdit->setMaximum(24);
hoursLabel->setBuddy(hoursEdit);
minutesLabel = new QLabel(tr("&Minutes:"));
minutesEdit = new QSpinBox;
minutesEdit->setMinimum(0);
minutesEdit->setValue(5);
minutesEdit->setMaximum(60);
minutesLabel->setBuddy(minutesEdit);
QGridLayout *durationLayout = new QGridLayout;
durationLayout->addWidget(permanentRadio, 0, 0, 1, 6);
durationLayout->addWidget(temporaryRadio, 1, 0, 1, 6);
durationLayout->addWidget(daysLabel, 2, 0);
durationLayout->addWidget(daysEdit, 2, 1);
durationLayout->addWidget(hoursLabel, 2, 2);
durationLayout->addWidget(hoursEdit, 2, 3);
durationLayout->addWidget(minutesLabel, 2, 4);
durationLayout->addWidget(minutesEdit, 2, 5);
QGroupBox *durationGroupBox = new QGroupBox(tr("Duration of the ban"));
durationGroupBox->setLayout(durationLayout);
QLabel *reasonLabel = new QLabel(tr("Please enter the reason for the ban.\nThis is only saved for moderators and "
"cannot be seen by the banned person."));
reasonEdit = new QPlainTextEdit;
QLabel *visibleReasonLabel =
new QLabel(tr("Please enter the reason for the ban that will be visible to the banned person."));
visibleReasonEdit = new QPlainTextEdit;
deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms"));
QPushButton *okButton = new QPushButton(tr("&OK"));
okButton->setAutoDefault(true);
connect(okButton, &QPushButton::clicked, this, &BanDialog::okClicked);
QPushButton *cancelButton = new QPushButton(tr("&Cancel"));
connect(cancelButton, &QPushButton::clicked, this, &BanDialog::reject);
QHBoxLayout *buttonLayout = new QHBoxLayout;
buttonLayout->addStretch();
buttonLayout->addWidget(okButton);
buttonLayout->addWidget(cancelButton);
QVBoxLayout *vbox = new QVBoxLayout;
vbox->addWidget(banTypeGroupBox);
vbox->addWidget(durationGroupBox);
vbox->addWidget(reasonLabel);
vbox->addWidget(reasonEdit);
vbox->addWidget(visibleReasonLabel);
vbox->addWidget(visibleReasonEdit);
vbox->addWidget(deleteMessages);
vbox->addLayout(buttonLayout);
setLayout(vbox);
setWindowTitle(tr("Ban user from server"));
}
WarningDialog::WarningDialog(const QString userName, const QString clientID, QWidget *parent) : QDialog(parent)
{
setAttribute(Qt::WA_DeleteOnClose);
descriptionLabel = new QLabel(tr("Which warning would you like to send?"));
nameWarning = new QLineEdit(userName);
nameWarning->setMaxLength(MAX_NAME_LENGTH);
warnClientID = new QLineEdit(clientID);
warnClientID->setMaxLength(MAX_NAME_LENGTH);
warningOption = new QComboBox();
warningOption->addItem("", "");
deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms"));
QPushButton *okButton = new QPushButton(tr("&OK"));
okButton->setAutoDefault(true);
connect(okButton, &QPushButton::clicked, this, &WarningDialog::okClicked);
QPushButton *cancelButton = new QPushButton(tr("&Cancel"));
connect(cancelButton, &QPushButton::clicked, this, &WarningDialog::reject);
QHBoxLayout *buttonLayout = new QHBoxLayout;
buttonLayout->addStretch();
buttonLayout->addWidget(okButton);
buttonLayout->addWidget(cancelButton);
QVBoxLayout *vbox = new QVBoxLayout;
vbox->addWidget(descriptionLabel);
vbox->addWidget(nameWarning);
vbox->addWidget(warningOption);
vbox->addWidget(deleteMessages);
vbox->addLayout(buttonLayout);
setLayout(vbox);
setWindowTitle(tr("Warn user for misconduct"));
}
void WarningDialog::okClicked()
{
if (nameWarning->text().simplified().isEmpty()) {
QMessageBox::critical(this, tr("Error"),
tr("User name to send a warning to can not be blank, please specify a user to warn."));
return;
}
if (warningOption->currentData().toString().simplified().isEmpty()) {
QMessageBox::critical(this, tr("Error"),
tr("Warning to use can not be blank, please select a valid warning to send."));
return;
}
accept();
}
QString WarningDialog::getName() const
{
return nameWarning->text().simplified();
}
QString WarningDialog::getWarnID() const
{
return warnClientID->text().simplified();
}
QString WarningDialog::getReason() const
{
return warningOption->currentData().toString().simplified();
}
int WarningDialog::getDeleteMessages() const
{
return deleteMessages->isChecked() ? -1 : 0;
}
void WarningDialog::addWarningOption(const QString warning, int startingIl)
{
if (startingIl > 1) {
warningOption->addItem(tr("%1 (IL %2)").arg(warning).arg(startingIl), warning);
} else {
warningOption->addItem(warning, warning);
}
}
void BanDialog::okClicked()
{
if (!nameBanCheckBox->isChecked() && !ipBanCheckBox->isChecked() && !idBanCheckBox->isChecked()) {
QMessageBox::critical(this, tr("Error"),
tr("You have to select a name-based, IP-based, clientId based, or some combination of "
"the three to place a ban."));
return;
}
if (nameBanCheckBox->isChecked()) {
if (nameBanEdit->text().simplified() == "") {
QMessageBox::critical(this, tr("Error"),
tr("You must have a value in the name ban when selecting the name ban checkbox."));
return;
}
}
if (ipBanCheckBox->isChecked()) {
if (ipBanEdit->text().simplified() == "") {
QMessageBox::critical(this, tr("Error"),
tr("You must have a value in the ip ban when selecting the ip ban checkbox."));
return;
}
}
if (idBanCheckBox->isChecked()) {
if (idBanEdit->text().simplified() == "") {
QMessageBox::critical(
this, tr("Error"),
tr("You must have a value in the clientid ban when selecting the clientid ban checkbox."));
return;
}
}
accept();
}
void BanDialog::enableTemporaryEdits(bool enabled)
{
daysLabel->setEnabled(enabled);
daysEdit->setEnabled(enabled);
hoursLabel->setEnabled(enabled);
hoursEdit->setEnabled(enabled);
minutesLabel->setEnabled(enabled);
minutesEdit->setEnabled(enabled);
}
QString BanDialog::getBanId() const
{
return idBanCheckBox->isChecked() ? idBanEdit->text() : QString();
}
QString BanDialog::getBanName() const
{
return nameBanCheckBox->isChecked() ? nameBanEdit->text() : QString();
}
QString BanDialog::getBanIP() const
{
return ipBanCheckBox->isChecked() ? ipBanEdit->text() : QString();
}
int BanDialog::getMinutes() const
{
return permanentRadio->isChecked() ? 0
: (daysEdit->value() * 24 * 60 + hoursEdit->value() * 60 + minutesEdit->value());
}
QString BanDialog::getReason() const
{
return reasonEdit->toPlainText();
}
QString BanDialog::getVisibleReason() const
{
return visibleReasonEdit->toPlainText();
}
int BanDialog::getDeleteMessages() const
{
return deleteMessages->isChecked() ? -1 : 0;
}
AdminNotesDialog::AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent)
: QDialog(_parent), userName(_userName)
{
setAttribute(Qt::WA_DeleteOnClose);
auto *updateButton = new QPushButton(tr("Update Notes"));
updateButton->setEnabled(false);
connect(updateButton, &QPushButton::clicked, this, &AdminNotesDialog::accept);
notes = new QPlainTextEdit(_notes);
notes->setMinimumWidth(500);
connect(notes, &QPlainTextEdit::textChanged, this, [=]() { updateButton->setEnabled(true); });
auto *vbox = new QVBoxLayout;
vbox->addWidget(notes);
vbox->addWidget(updateButton);
setLayout(vbox);
setWindowTitle(tr("Admin Notes for %1").arg(_userName));
}
QString AdminNotesDialog::getNotes() const
{
return notes->toPlainText();
}
namespace UserListRoles
{
@ -613,7 +299,12 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
userTree->hideColumn(3);
connect(userTree, &QTreeWidget::itemActivated, this, &UserListWidget::userClicked);
userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
userTree->header()->setStretchLastSection(true);
// QTreeWidget enables stretchLastSection by default. Left on, the hidden
// last section absorbs viewport resizes, the Stretch sections never
// redistribute, and the header keeps a stale length past the viewport —
// an invisible horizontal pan range under ScrollBarAlwaysOff. Disable it
// so the explicit resize modes in applyDisplayMode() own the geometry.
userTree->header()->setStretchLastSection(false);
// Always create timers so callers never segfault on a null deref;
// showPopupForUser / hidePopup already guard against a null userInfoPopup.
@ -654,6 +345,11 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
&cardArtProvider->cache(), &cardArtParamsMap,
window()); // parented to main window so it floats above siblings
// The invite availability is scoped to the room this list belongs to,
// and gated on the room's buddy-only setting for the hovered user.
userInfoPopup->setGameInviteAvailable(
[this](const QString &userName) { return userContextMenu->hasGameInviteLink(userName); });
userInfoPopup->hide();
userInfoPopup->setWindowOpacity(0.0);
userInfoPopup->installEventFilter(this);
@ -930,13 +626,24 @@ void UserListWidget::applyDisplayMode()
{
const bool styled = SettingsCache::instance().appearance().getStyleUserList();
// Both modes must keep the header length at the viewport width: with
// ScrollBarAlwaysOff a nonzero horizontal range is invisible but still
// pans via trackpad gestures, which reads as janky random drift.
if (styled) {
userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch);
userTree->hideColumn(1);
userTree->hideColumn(2);
userTree->hideColumn(3);
} else {
userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents);
// Bounded widths instead of ResizeToContents: content sizing measures
// the FULL text width while the delegate elides afterwards, so long
// names widened the header past the viewport. Fixed icon columns plus
// a stretched name column keep the range at zero, eliding trims.
userTree->header()->setSectionResizeMode(0, QHeaderView::Fixed);
userTree->header()->resizeSection(0, 24);
userTree->header()->setSectionResizeMode(1, QHeaderView::Fixed);
userTree->header()->resizeSection(1, 22);
userTree->header()->setSectionResizeMode(2, QHeaderView::Stretch);
userTree->showColumn(1);
userTree->showColumn(2);
userTree->hideColumn(3);
@ -960,6 +667,8 @@ void UserListWidget::connectPopupSignals()
// Wire all action signals to UserContextMenu::exec*()
connect(userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat);
connect(userInfoPopup, &UserInfoPopup::inviteRequested, this,
[this](const QString &userName) { userContextMenu->execInvite(userName); });
connect(userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails);
connect(userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames);
connect(userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy);

View file

@ -16,9 +16,7 @@
#include "user_list_painter.h"
#include <QComboBox>
#include <QDialog>
#include <QGroupBox>
#include <QQueue>
#include <QSet>
#include <QStyledItemDelegate>
#include <QTextEdit>
@ -31,80 +29,12 @@ class QTreeWidget;
class ServerInfo_User;
class AbstractClient;
class TabSupervisor;
class QLabel;
class QCheckBox;
class QSpinBox;
class QRadioButton;
class QPlainTextEdit;
class Response;
class CommandContainer;
class UserContextMenu;
class UserListWidget;
class QShowEvent;
class BanDialog : public QDialog
{
Q_OBJECT
private:
QLabel *daysLabel, *hoursLabel, *minutesLabel;
QCheckBox *nameBanCheckBox, *ipBanCheckBox, *idBanCheckBox, *deleteMessages;
QLineEdit *nameBanEdit, *ipBanEdit, *idBanEdit;
QSpinBox *daysEdit, *hoursEdit, *minutesEdit;
QRadioButton *permanentRadio, *temporaryRadio;
QPlainTextEdit *reasonEdit, *visibleReasonEdit;
private slots:
void okClicked();
void enableTemporaryEdits(bool enabled);
public:
explicit BanDialog(const ServerInfo_User &info, QWidget *parent = nullptr);
[[nodiscard]] QString getBanName() const;
[[nodiscard]] QString getBanIP() const;
[[nodiscard]] QString getBanId() const;
[[nodiscard]] int getMinutes() const;
[[nodiscard]] QString getReason() const;
[[nodiscard]] QString getVisibleReason() const;
[[nodiscard]] int getDeleteMessages() const;
};
class WarningDialog : public QDialog
{
Q_OBJECT
private:
QLabel *descriptionLabel;
QLineEdit *nameWarning;
QComboBox *warningOption;
QLineEdit *warnClientID;
QCheckBox *deleteMessages;
private slots:
void okClicked();
public:
WarningDialog(const QString userName, const QString clientID, QWidget *parent = nullptr);
[[nodiscard]] QString getName() const;
[[nodiscard]] QString getWarnID() const;
[[nodiscard]] QString getReason() const;
[[nodiscard]] int getDeleteMessages() const;
void addWarningOption(const QString warning, int startingIl = 1);
};
class AdminNotesDialog : public QDialog
{
Q_OBJECT
private:
QString userName;
QPlainTextEdit *notes;
public:
explicit AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent = nullptr);
[[nodiscard]] QString getName() const
{
return userName;
}
[[nodiscard]] QString getNotes() const;
};
class UserListItemDelegate : public QStyledItemDelegate
{
QTreeWidget *tree;

View file

@ -5,6 +5,7 @@
#include "../../client/settings/card_counter_settings.h"
#include "../../palette_editor/palette_editor_dialog.h"
#include "../dialogs/override_printing_warning.h"
#include "../general/home_tab_button_color.h"
#include "../interface/theme_manager.h"
#include "../interface/widgets/general/background_sources.h"
#include "../playmat/playmat_collection_dialog.h"
@ -131,6 +132,14 @@ AppearanceSettingsPage::AppearanceSettingsPage()
connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(),
&AppearanceSettings::setHomeTabDisplayCardName);
for (const auto &entry : HomeTabButtonColor::all()) {
homeTabButtonColorSourceBox.addItem(QObject::tr(entry.trKey));
}
homeTabButtonColorSourceBox.setCurrentIndex(settings.appearance().getHomeTabButtonColorSourceIndex());
connect(&homeTabButtonColorSourceBox, QOverload<int>::of(&QComboBox::currentIndexChanged), &settings.appearance(),
&AppearanceSettings::setHomeTabButtonColorSourceIndex);
updateHomeTabSettingsVisibility();
auto *homeTabGrid = new QGridLayout;
@ -139,10 +148,54 @@ AppearanceSettingsPage::AppearanceSettingsPage()
homeTabGrid->addWidget(&homeTabBackgroundShuffleFrequencyLabel, 1, 0);
homeTabGrid->addWidget(&homeTabBackgroundShuffleFrequencySpinBox, 1, 1);
homeTabGrid->addWidget(&homeTabDisplayCardNameCheckBox, 2, 0, 1, 2);
homeTabGrid->addWidget(&homeTabButtonColorSourceLabel, 3, 0);
homeTabGrid->addWidget(&homeTabButtonColorSourceBox, 3, 1);
homeTabGroupBox = new QGroupBox;
homeTabGroupBox->setLayout(homeTabGrid);
// Playmat settings
playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll);
playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly);
playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone);
int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility());
if (visIdx >= 0) {
playmatVisibilityCombo.setCurrentIndex(visIdx);
}
connect(&playmatVisibilityCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt());
});
playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo);
// Playmat mode: Override / Fallback / Deck-only
playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck);
playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback);
playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly);
int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode());
if (modeIdx >= 0) {
playmatModeCombo.setCurrentIndex(modeIdx);
}
connect(&playmatModeCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt());
});
playmatModeLabel.setBuddy(&playmatModeCombo);
// User-level playmat settings: fallback collection.
connect(&playmatDefaultEditButton, &QPushButton::clicked, this,
&AppearanceSettingsPage::openPlaymatCollectionDialog);
auto *playmatGrid = new QGridLayout;
playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1);
playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1);
playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1);
playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1);
playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1);
playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1);
playmatGroupBox = new QGroupBox;
playmatGroupBox->setLayout(playmatGrid);
// Styling settings
styleUserListCheckBox.setChecked(settings.appearance().getStyleUserList());
connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(),
&AppearanceSettings::setStyleUserList);
@ -248,7 +301,6 @@ AppearanceSettingsPage::AppearanceSettingsPage()
cardLayoutGroupBox->setLayout(cardLayoutGrid);
// Card counter colors
auto *cardCounterColorsLayout = new QGridLayout;
cardCounterColorsLayout->setColumnStretch(1, 1);
cardCounterColorsLayout->setColumnStretch(3, 1);
@ -328,47 +380,6 @@ AppearanceSettingsPage::AppearanceSettingsPage()
tableGroupBox = new QGroupBox;
tableGroupBox->setLayout(tableGrid);
// Playmat settings
playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll);
playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly);
playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone);
int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility());
if (visIdx >= 0) {
playmatVisibilityCombo.setCurrentIndex(visIdx);
}
connect(&playmatVisibilityCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt());
});
playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo);
// Playmat mode: Override / Fallback / Deck-only
playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck);
playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback);
playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly);
int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode());
if (modeIdx >= 0) {
playmatModeCombo.setCurrentIndex(modeIdx);
}
connect(&playmatModeCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt());
});
playmatModeLabel.setBuddy(&playmatModeCombo);
// User-level playmat settings: fallback collection.
connect(&playmatDefaultEditButton, &QPushButton::clicked, this,
&AppearanceSettingsPage::openPlaymatCollectionDialog);
auto *playmatGrid = new QGridLayout;
playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1);
playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1);
playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1);
playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1);
playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1);
playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1);
playmatGroupBox = new QGroupBox;
playmatGroupBox->setLayout(playmatGrid);
// putting it all together
auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(themeGroupBox);
@ -497,6 +508,15 @@ void AppearanceSettingsPage::retranslateUi()
homeTabBackgroundShuffleFrequencyLabel.setText(tr("Home tab background shuffle frequency:"));
homeTabBackgroundShuffleFrequencySpinBox.setSpecialValueText(tr("Disabled"));
homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right"));
homeTabButtonColorSourceLabel.setText(tr("Home tab button color:"));
homeTabButtonColorSourceBox.setToolTip(
tr("Automatic: extract from background if present, otherwise use theme default"));
playmatGroupBox->setTitle(tr("Playmat settings"));
playmatVisibilityLabel.setText(tr("Playmat visibility:"));
playmatModeLabel.setText(tr("Default collection behavior:"));
playmatDefaultLabel.setText(tr("Default playmat collection:"));
playmatDefaultEditButton.setText(tr("Edit..."));
stylingGroupBox->setTitle(tr("Styling settings"));
styleUserListCheckBox.setText(tr("Style user list"));
@ -540,9 +560,4 @@ void AppearanceSettingsPage::retranslateUi()
tableGroupBox->setTitle(tr("Table grid layout"));
invertVerticalCoordinateCheckBox.setText(tr("Invert vertical coordinate"));
minPlayersForMultiColumnLayoutLabel.setText(tr("Minimum player count for multi-column layout:"));
playmatGroupBox->setTitle(tr("Playmat settings"));
playmatVisibilityLabel.setText(tr("Playmat visibility:"));
playmatModeLabel.setText(tr("Default collection behavior:"));
playmatDefaultLabel.setText(tr("Default playmat collection:"));
playmatDefaultEditButton.setText(tr("Edit..."));
}

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