Compare commits

...

54 commits

Author SHA1 Message Date
tooomm
d8fa4af393
format 2026-09-12 16:38:52 +02:00
tooomm
860ff503b1
Merge branch 'master' into tooomm-patch-33 2026-09-12 16:35:36 +02:00
tooomm
a79be1ca12 cleanup 2026-09-12 15:51:29 +02:00
BruebachL
a85203e457
Migrate theme asset loads to scheme-variant resolution (#7209-2) (#7276)
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
Replace remaining QPixmap("theme:...") call sites with themePixmap() so icons, replay controls, card backs, and other images resolve to -dark/-light variants under theme schemes. Stem-exact 1:1 migration; behavior unchanged for non-variant themes.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-11 17:59:57 +02:00
BruebachL
e9bf1e6e46
[Theme] Add scheme-variant asset resolution (#7275)
* Add scheme-variant theme asset resolution (#7209-1)

ThemeManager::assetPath() and schemeVariantPath() resolve a theme asset to its scheme-variant file (prefix-light/dark.png) with fallback to the plain asset, and themePixmap()/loadBrush()/loadExtraBrush() use them. CSS files load style-dark.css or style-light.css when present. Home widget re-resolves its background on theme change.

Link pixel_map_generator.cpp into the oracle target, which needs Qt6::Xml for QDomDocument.

* Fix clang-format wrap of theme format probe lists

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-11 17:59:57 +02:00
BruebachL
202a5ac958
[Client/Server/Protocol] Surface live metrics in the Developer tab (#7212)
* [Server] Instrument command processing, game starts, and event loops

Add a lock-free MetricsRegistry that accumulates per-command processing
times in preallocated histogram slots (one per protobuf command type,
bucketed at 1/5/10/25/50/100/250/500/1000/2500/5000 ms +Inf). The
hot-path observeCommand() uses only relaxed atomic adds — no locks,
no allocations, no cache-line ping-pong beyond the unavoidable counter
updates.

Wire the registry into AbstractServerSocketInterface::processCommandContainer()
so every processed command is attributed with its container's wall-clock
time. When a container exceeds metrics/slow_command_ms (default 500),
a warning is logged including the connected username.

Add an EventLoopWatchdog heartbeat that runs on every socket pool thread.
If a heartbeat overshoots metrics/stall_warn_ms (default 2000 ms), the
overshoot is recorded in atomic counters and a warning is logged. Both
thresholds are configurable in servatrice.ini; setting stall_warn_ms to 0
disables the watchdogs entirely.

Track game-start durations via a separate histogram in MetricsRegistry.
Server_Game::startGameNow() measures the time from zone creation through
player materialization and reports it via Server::observeGameStartDurationMs().

Add a live card-count gauge: Server_Game exposes getCardsInGame() and
Servatrice::getCardsInGamesTotal() sums across all running games under
the appropriate read locks.

Include a standalone metrics_registry_test (Google Test) that validates
empty registries, single/multi-sample histograms, kind encoding,
overflow-slot collapse, negative-duration clamping, gauge rendering,
and the game-start histogram separation.

Took 10 minutes

* [Client/Server/Protocol] Surface live metrics in the Developer tab

Extend Response_GetServerStats with live counters from the in-process
MetricsRegistry: cards in games, event loop stall totals/worst,
total commands processed, average command time, active command types,
and game-start count/duration. Add a repeated CommandStats message
carrying per-command breakdowns (kind, extension number, resolved
protobuf name, count, total ms) for every type that has seen at
least one sample.

Server-side cmdGetServerStats() populates all new fields after the
existing DB uptime snapshot query, resolving protobuf extension names
via the descriptor pool for human-readable labels like
session/Command_Ping.

Expand TabDeveloper with two tables: an overview section (existing
DB stats plus the new live metrics) and a per-command breakdown table
(Command / Count / Total ms / Avg ms) sorted by total_ms descending
so the hottest commands surface first.

Took 55 minutes

Took 47 seconds

* [Server] Drop dead Prometheus histogram, add developer command metrics, fix watchdog init order

- metrics_registry: remove toPrometheusText/appendCumulativeBuckets and the time-bucket histogram that nothing in production ever emitted (the future /metrics exporter can bring it back); keep counts/totals read by the Developer tab
- Fix +Inf bucket routing that never incremented, and its test that locked the bug in
- Instrument developer_command container (kind 6) in processCommandContainer and stats label resolution
- Read metrics/{slow_command_ms,stall_warn_ms} at the top of initServer() so stall_warn_ms=0 disables the watchdogs before pool threads start
- Shrink KindStride to 1280 (largest extension in use is 1206) with a static_assert; document scrape cost of getCardsInGamesTotal; note slow_command logging has no rate limit in servatrice.ini.example

* [Tests] Give metrics_registry_test an explicit main

* [Server] Record only the dispatched command family; drop unused totals

processCommandContainer recorded every family in a container even though
the base if/else-if dispatch processes at most one. An unauthenticated
client could batch a session command (login) with fabricated developer,
moderator, and admin entries and forge genuine-looking samples that were
never executed or authorized. Mirror the base's selection, skip when the
handler was already deleted, and skip entries whose extension number is
-1 (which would otherwise wrap into the previous kind's id range).

[Server] Drop dead process-lifetime byte/uptime counters

txBytesTotal/rxBytesTotal added an atomic RMW to every socket write and
read for counters nothing consumes (cmdGetServerStats fills tx_bytes,
rx_bytes, and uptime_secs from the DB snapshot). Remove the two atomics
and the getTxBytesTotal/getRxBytesTotal/getUptimeSeconds getters; the
incTxBytes/incRxBytes slots and mutexes remain for the ISL legacy
counters.

[Protocol] Document kind 5 as developer in CommandStats

NumKinds is 6 and the server emits kind_index = 5 for developer
commands; the comment stopped at 4.

* [Client] Togglable auto-refresh for Developer stats tab

* [Oracle] Fix clang-format alignment of card type priority list

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-11 17:18:56 +02:00
BruebachL
d5d99e4dfb
[Server/Client/Protocol] Add developer staff role (#7211)
* [Server/Client/Protocol] Add developer staff role

Introduce a Developer staff level (proto flag 32, DB admin bit 8) that
sits between admin and moderator: no kick/ban/warn/report/admin powers,
but gets server log access via a new developer command container family
(GET_SERVER_STATS, VIEWLOG_HISTORY) and an idle-timeout exemption.

- Protocol: IsDeveloper flag, developer_commands.proto envelope,
  Command_GetServerStats/Command_GetLogHistory, Response_GetServerStats,
  Command_AdjustMod.should_be_developer
- Servatrice: fail-closed developer dispatcher, uptime snapshot handler,
  shared log history handler reuse, bit-8 DB mapping
- Client: burgundy pawn/badge/labels/sort order, prepareDeveloperCommand,
  minimal Developer stats tab, log tab access, promote/demote actions

Took 24 minutes

Took 18 seconds

* [Server/Client/Protocol] Address developer role review feedback

Address ZeizaZach's review of the developer staff role:

- Nudge the developer log query to exclude private chat and sender IPs
  (the ModeratorCommand path still sees everything).
- Deduplicate Command_GetLogHistory into Command_ViewLogHistory, which now
  extends both ModeratorCommand (ext) and DeveloperCommand (dev_ext); the
  client picks the DeveloperCommand-scoped extension by extendee, and the
  server reads it via the extension number.
- Pull the uptime snapshot SQL into Servatrice_DatabaseInterface as
  getLatestUptimeSnapshot() and widen the reported counters to 64-bit.
- Document the admin bitfield (1 admin, 2 moderator, 4 judge, 8 developer)
  and add a server-side test for the developer command path.

* Add missing trailing newline to user_context_menu.cpp

* Remove stale includes of deleted command_get_log_history proto

The Command_GetLogHistory message was folded into Command_ViewLogHistory,
which deleted command_get_log_history.proto, but serversocketinterface
still #included its generated header. Fresh CI builds fail on the missing
file; local builds masked it by reusing a previously generated header.

* [Server] Exclude chat rows when private-chat filter is bypassable

A developer who omits log_location entirely — or sends only "chat" —
leaves chatType, gameType, roomType all false, so getMessageLogHistory
skips the target_type clause and returns every row, private messages
included. When !allowPrivateChat the server now forces game+room when
no surviving location was requested, guaranteeing the query always
carries a target_type restriction.

[Client] Demote mod+dev to moderator path in log-tab dispatch

The developer command family is strictly weaker than the moderator one
(no private chat, no sender_ip, ip filter ignored), so granting the
developer bit to an existing moderator must not silently strip their
capabilities. useDeveloperCommands is now true only when the user holds
the developer bit and not the moderator bit.

[Client] Hide the IP-address filter for developer log tab users

The developer path ignores the ip_address query field server-side.
Showing the field lets a developer type an IP and get results that are
silently unfiltered by it rather than an empty result set — reads as a
broken filter. Hide labelFindIPAddress/findIPAddress alongside the
privateChat checkbox.

* Developer pawn is silver.

* [Client] Fix indentation of merged Card Art Rules / Developer tabs

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-11 17:18:56 +02:00
BruebachL
7d867b9745
[Oracle/Client] Report card database download progress (#7253)
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
* [Oracle/Client] Report card database download progress

Card database updates ran invisibly: MTGJSON parsing spun an indeterminate
bar on the UI thread and the set import blocked the window, while the
onboarding wizard spawned `oracle -b` with no progress to show at all.

- Add byte-level scan progress to `RawJson::scanSetRanges` via an optional
  callback, throttled to ~100 reports per scan.
- Emit `OracleImporter::dataReadProgress` during the scan and import sets on a
  worker thread, driving the wizard's progress bar per set.
- With `-b`, write machine-readable `PROGRESS <stage> <done> <total>` lines to
  stdout for the download/scan/import stages; stderr keeps the log output.
- Parse the oracle stdout in `MainWindow` and forward it to the onboarding
  wizard, giving the card database step a determinate bar with stage-specific
  status text.
- Guard the async workers against the wizard being closed mid-run.
- Add Google Test coverage for scan progress reporting.

* [Oracle/Client] Harden oracle progress workers and quit prompt

Address review feedback on the download-progress change: decompress and read
sets files off the UI thread, cancel the load/import workers before the
wizard can tear down the importer, and show an 'Extracting file...' status
plus a clean 100% tail so the poll never looks stuck. Quitting Cockatrice
while a card database update runs now asks for confirmation.

* Show 100% for 500ms on complete.

* Disable buttons on set import until done.

* Clean up progress bar.

* Drop wrapper around lambda

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-09 22:27:16 +02:00
BruebachL
69e8f80fa1
[Oracle] Prefer higher-priority maintype when merging split cards (#7272)
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
* [Oracle] Prefer higher-priority maintype when merging split cards

Adventure cards (e.g. Bonecrusher Giant, Virtue of Knowledge) are stored as
split cards in MTGJSON, with a Creature/permanent face and an Instant/Sorcery
adventure face. When the two faces are merged into a single card, the code
previously discarded the second face's maintype entirely, keeping whichever
face was processed first.

If the Instant/Sorcery adventure face appeared first, the merged card got
maintype 'Instant' with tableRow 3. At runtime this made double-clicking the
card on the stack send it to the graveyard instead of the table
(PlayerActions::playCard).

Fix the merge to follow the same priority order used by getMainCardType()
(Planeswalker > Creature > Land > Sorcery > Instant > Artifact > Enchantment),
so the permanent Creature type wins for adventure cards regardless of face
order, matching the physical card and the reported expectations.

Closes #4394

* Extract helper.

* Simplify const

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-07 21:22:53 +02:00
BruebachL
df3defa890
[PictureLoader] Leave failed pixmap null so solid color is shown instead. (#7274)
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
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-07 13:37:19 +02:00
BruebachL
030e4f5871
[Game] Stop duplicate attach log entries (#7273)
Fix duplicate attach messages in the game log caused by a redundant
child arrow in CardItem::drawAttachArrow.

Unlike the sibling drawArrow, drawAttachArrow omitted the ``card ==
this`` guard when iterating selectedItems(). Because right-clicking a
card to open the attach menu selects that card, it was always present
in selectedItems(), producing a second arrow for the same source card.

On release both arrows sent an identical Command_AttachCard, so the
server broadcast two Event_AttachCard messages and the log rendered
"attaches to" twice. Mirror the drawArrow skip condition so the active
card is excluded and only one attach command is sent.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-07 13:37:13 +02:00
RickyRister
6e5c58069b
[Settings] Make chat settings page scrollable (#7271)
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-07 00:52:06 -07:00
BruebachL
ebeae48652
[Game] Preserve hand card order when restoring connection (#7266)
When a player restores connection to a game, the client rebuilds each
zone from the cards the server sends in the game state. Non-coordinate
zones (hand, piles, stack) report x == 0 on every card, so inserting
each rebuilt card at that coordinate reversed the received order one
card at a time.

Append rebuilt cards in the order they arrive for zones without
coordinates; coordinate-based zones (table) keep using x/y.

Fixes #2759

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-07 08:21:44 +02:00
BruebachL
36f998e466
[Client] Fix pawn avatar cache key collision for players without a custom avatar (#4086) (#7264)
Player pawns are looked up in QPixmapCache under a key built from the
rendered size, user level, and the avatar pixmap's cacheKey(). A null
pixmap reports cacheKey() 0, so all players without a custom avatar
collided: the first pawn rendered for a given size and user level was
reused for the next one, showing the wrong player's pawn.

Extend the key with the rendered height, the lowercased privlevel
(matching UserLevelPixmapGenerator), and both pawn colors so that every
visually distinct pawn gets its own cache entry.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-07 08:21:26 +02:00
BruebachL
760fe88fa3
[Client] Add setting to ignore all private messages (#7260)
* [Client] Add setting to ignore all private messages (#1250)

* Early return

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-07 08:20:46 +02:00
BruebachL
fff506dbbe
[Client] Show confirmation when adding a user to the ignore list (#1875) (#7261)
Nothing in the UI confirmed that a user had been added to the ignore
list, so the action felt ambiguous and could be repeated by accident.

Show an information dialog when the server acknowledges the
add-to-ignore command.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-07 08:14:39 +02:00
BruebachL
8e0bdafb14
[Client] Prevent contacting users on the ignore list (#7262)
* [Client] Prevent contacting users on the ignore list (#1249)

The ignore list silences incoming messages, but a user on it was still
reachable: the context menu's chat item stayed enabled, private messages
could be sent, and a chat tab could be opened for an ignored user.

Make ignored users uncontactable: disable the chat item for them, refuse
to deliver messages typed in an open PM tab with one, and refuse to open
a new private chat tab with an ignored user (with a hint on how to undo
the ignore).

* Update cockatrice/src/interface/widgets/tabs/tab_message.cpp

Co-authored-by: RickyRister <42636155+RickyRister@users.noreply.github.com>

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
Co-authored-by: RickyRister <42636155+RickyRister@users.noreply.github.com>
2026-09-07 08:14:15 +02:00
BruebachL
9b0d62c152
Close all zone views of a leaving player to avoid crashing (#4298) (#7270)
When a player left, GameScene::removePlayer iterated zoneViews while close()
synchronously removed the current view from that list. A judge with several open
views of the departing player (e.g. library and hand) only had the first one
closed; the remaining views were skipped and left pointing at a player that was
about to be deleted, crashing the client on the next access.

- GameScene::removePlayer: iterate over a copy of zoneViews
- GameScene::toggleZoneView: same fix for the identical iteration bug

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-07 02:51:38 +02:00
BruebachL
6cdeb0c428
[Client] Explain the buddy and ignore lists in the user tab (#2072) (#7258)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-07 02:29:17 +02:00
BruebachL
745e94f332
[Replay] Skip damage animations when skipping backward in replays (#7249)
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-07 02:26:52 +02:00
BruebachL
b01e107908
[Client] Show horizontal art for plane and siege cards in the profile banner (#7118) (#7250)
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
Use the landscape orientation flag to rotate sideways-layout card art
upright before cropping, so planes/sieges show their horizontal art as
the server profile banner card instead of a rotated full card.

- Curve cropCardArt around the card's landscapeOrientation flag with
  landscape-specific art margins (mirrors CardInfoPictureWidget)
- Add shared CardArtUtils::rotateSidewaysLayoutArt helper and apply it
  to the playmat (game render and settings preview) and card info widget,
  replacing three duplicate 90-degree rotation blocks

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-07 02:20:54 +02:00
BruebachL
8d30ac54f0
[Game] Prevent spectator duplication when replaying joined events (#7248)
* [Game] Prevent spectator duplication when replaying joined events

The spectator branch of eventJoin emitted spectatorJoined unconditionally
even when the spectator was already present (e.g. replayed during a rewind).
Guard it like the player branch and eventGameStateChanged, and make
PlayerListWidget::addPlayer idempotent as defense in depth.

* In resetChatAndPhase() (the rewound() handler), also clear all spectators from both PlayerManager and PlayerListWidget before the replay rebuilds from event 0. The forward replay then re-adds exactly the spectators whose join events fall within the new time range via eventGameStateChanged/eventJoin.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-07 01:57:35 +02:00
tooomm
fb482f037e
550 MB --> 600 MB (#7257) 2026-09-06 23:13:34 +02:00
tooomm
0f0e46a177
[Oracle] Have space between size value and unit (#7254) 2026-09-06 22:19:13 +02:00
tooomm
2fe59d6326
fix table formatting (#7256) 2026-09-06 22:18:36 +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
233 changed files with 7945 additions and 1752 deletions

View file

@ -8,6 +8,7 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \
gtest \
mariadb-libs \
ninja \
openssl \
protobuf \
qt6-base \
qt6-declarative \

View file

@ -15,6 +15,7 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \

View file

@ -16,6 +16,7 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \

View file

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

View file

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

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,6 +15,7 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \

View file

@ -16,6 +16,7 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \

View file

@ -150,6 +150,11 @@ if [[ $MAKE_TEST == "1" ]]; then
fi
if [[ $USE_CCACHE == "1" ]]; 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 [[ -n $CCACHE_SIZE ]]; then
# 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

View file

@ -152,7 +152,7 @@ jobs:
env:
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }} # directory for caching docker image and ccache
CCACHE_EVICTION_AGE: 7d
CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }}
@ -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
@ -338,7 +342,7 @@ jobs:
timeout-minutes: 100
env:
CCACHE_DIR: ${{ github.workspace }}/.cache/
CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
steps:
- name: "Checkout"

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

@ -4,8 +4,8 @@
# Cockatrice, Oracle, Servatrice, Test
# 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)
# CMake 3.16 is required for Qt6 and target_precompile_headers()
cmake_minimum_required(VERSION 3.16)
# Compile Cockatrice
option(WITH_CLIENT "Build Cockatrice client" ON)
@ -18,10 +18,10 @@ option(TEST "Build tests" OFF)
# Check for translation updates
option(UPDATE_TRANSLATIONS "Update translations on compile" OFF)
# Use compiler cache (ccache)
option(USE_CCACHE "Cache the build results with ccache" ON)
# Use vcpkg regardless of OS
option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
# Use compiler cache (ccache)
option(USE_CCACHE "Cache the build results with ccache" OFF)
# Treat warnings as errors (Debug builds only)
option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON)
@ -32,20 +32,26 @@ if(NOT CMAKE_BUILD_TYPE)
Release
CACHE STRING "Build type"
)
else()
set(CMAKE_BUILD_TYPE
Release
CACHE STRING "Type of build"
)
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)
@ -82,19 +88,13 @@ if(NOT DEFINED GIT_TAG_RELEASENAME)
set(GIT_TAG_RELEASENAME "Graduation Day")
endif()
# Use C++20 for all targets
set(CMAKE_CXX_STANDARD
20
CACHE STRING "C++ ISO Standard"
)
set(CMAKE_CXX_STANDARD_REQUIRED True)
# Requires ISO C++20 standard (without compiler-specific extensions)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# try target_compile_features(cockatrice PRIVATE cxx_std_20)
# -fexperimental-library for gcc/clang, libstdc++, gate with compiler_is_gnucxx or cxx_compiler_id matches clang
# Set conventional loops
set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS true)
set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS ON)
# Search path for CMake modules
set(COCKATRICE_CMAKE_PATH "${PROJECT_SOURCE_DIR}/cmake")
@ -144,41 +144,41 @@ elseif(WIN32) # Windows (including 64bit)
endif()
# Define compiler flags
if(MSVC) # MS Visual C++ compiler
if(CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") # MS Visual C++ compiler
# /EHsc Enable standard C++ exception handling
# /MP Enable parallel compilation
# /permissive- Enable strict standards compliance
# /permissive- Enable more standards-conforming behavior
# /utf-8 Set source file encoding and execution char set to UTF-8
# /W4 Enable warning level 4
# /Zc:__cplusplus Enable C++20 detection in headers
# /Zi Generate debugging information (Program Database, PDB)
set(CMAKE_CXX_FLAGS "/EHsc /MP /permissive- /utf-8 /W4 /Zc:__cplusplus /Zi")
# /Ox Enable maximum optimization
# /O2 Balanced optimization
# /MD Link against the multi-threaded DLL runtime library (Release CRT)
set(CMAKE_CXX_FLAGS_RELEASE "/Ox /MD")
set(CMAKE_CXX_FLAGS_RELEASE "/O2 /MD")
# /Od Disable optimization
# /MDd Link against the multi-threaded Debug DLL runtime library (Debug CRT)
set(CMAKE_CXX_FLAGS_DEBUG "/Od /MDd")
# Generate PDBs, even when building release target to allow developers to better analyze crash logs
# /DEBUG Enable PDB generation also for Release builds
# /OPT:REF Remove unused references
# /OPT:ICF Fold identical code
# /DEBUG Enable debug symbols and PDB generation
# /OPT:REF Remove unreferenced functions/data
# /OPT:ICF Fold identical COMDAT functions/data
set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF")
elseif(CMAKE_COMPILER_IS_GNUCXX) # Linux/GCC, BSD/GCC, Windows/MinGW
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") # GCC compiler
include(CheckCXXCompilerFlag)
# -O2 Balanced optimization
# -s Remove debug info from the binary (strip symbols)
# -s Remove symbols from the executable <-- do we want that? we add symbols to windows builds explicitly. Check pdb's and how debug symbols work & can be striped during packaging // non-gcc & clang section also does not have it
set(CMAKE_CXX_FLAGS_RELEASE "-O2 -s")
# -ggdb Produce GDB debugging symbols
# -O0 No optimization
# -Wall Enable all warnings
# -Wextra Enable extra warnings
# -O0 Disable optimization
# -Wall Enable broad set of useful warnings
# -Wextra Enable set of extra warnings
# -Werror Treat warnings as compilation errors
if(WARNING_AS_ERROR)
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra -Werror")
@ -186,26 +186,27 @@ elseif(CMAKE_COMPILER_IS_GNUCXX) # Linux/GCC, BSD/GCC, Windows/MinGW
set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra")
endif()
# Test without, CMAKE_CXX-ESTENSIONS are defined as OFF, so no sense to add them here but not turning the general setting ON?
# -std=gnu++20 Enable GNU C++20 extensions
if(APPLE) # macOS/GCC
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++20")
endif()
# if(APPLE) # macOS/GCC
# set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++20")
# endif()
# -Wcast-align Catch unsafe pointer casts
# -Wmissing-declarations Catch defined functions without prior declaration
# -Wno-error=extra Downgrade some -Wextra warnings from errors to warnings
# -Wno-error=delete-non-virtual-dtor <-- see if still needed?
# -Wno-error=delete-non-virtual-dtor xxx
# -Wno-error=sign-compare Downgrade comparing signed vs. unsigned integers from errors to warnings
# -Wno-error=missing-declarations Downgrade -Wmissing-declarations from errors to warnings
# -Wno-error=sfinae-incomplete GCC 16+: Qt MOC + protobuf forward declarations trigger this
set(ADDITIONAL_DEBUG_FLAGS
-Wcast-align
-Wmissing-declarations
-Wno-error=extra
-Wno-error=delete-non-virtual-dtor
-Wno-error=sign-compare
-Wno-error=missing-declarations
-Wno-error=sfinae-incomplete
-Wno-error=extra # <-- consider removing this, check without as it makes most of added -Wextra warnings non-fatal
-Wno-error=delete-non-virtual-dtor # <-- see if still needed?
-Wno-error=sign-compare # <-- test without
-Wno-error=missing-declarations # <-- test without
-Wno-error=sfinae-incomplete # <-- test again without and see if comment still holds true
)
foreach(FLAG ${ADDITIONAL_DEBUG_FLAGS})
@ -214,26 +215,38 @@ elseif(CMAKE_COMPILER_IS_GNUCXX) # Linux/GCC, BSD/GCC, Windows/MinGW
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}")
endif()
endforeach()
else() # Other: macOS/LLVM, BSD/LLVM, "Clang-specific"
# Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang compiler
# -O2 Balanced optimization
set(CMAKE_CXX_FLAGS_RELEASE "-O2")
# -g Include debug information (equivalent to -ggdb for GCC)
# -O0 No optimization
# -Wall Enable all warnings
# -Wextra Enable extra warnings
# -Werror Treat warnings as compiler errors
# -O0 Disable optimization
# -Wall Enable broad set of useful warnings
# -Wextra Enable set of extra warnings
# -Werror Treat warnings as compilation errors
# -Wno-unused-parameter Suppress warnings about unused function parameters (common in Qt callbacks)
if(WARNING_AS_ERROR)
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra -Werror -Wno-unused-parameter")
else()
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") # <-- required for clang? see adding pr description again
else() # Undefined compiler
message(WARNING "Unknown C++ compiler: ${CMAKE_CXX_COMPILER_ID}")
endif()
# GNU systems need to define the Mersenne Exponent for SFMT for the RNG to compile without warning
# Consider making this target specific --> target_compile_definitions(libcockatrice_rng PRIVATE SFMT_MEXP=19937)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
add_definitions("-DSFMT_MEXP=19937")
add_compile_definitions(SFMT_MEXP=19937)
endif()
find_package(Threads REQUIRED)
@ -276,11 +289,6 @@ if(WIN32) # Windows (including 64bit)
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()
@ -404,3 +412,10 @@ if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3)
# Qt 6.3+ requires project finalization to support translations
qt6_finalize_project()
endif()
# Print compiler identification at configuration time
message(STATUS "C++ compiler: ${CMAKE_CXX_COMPILER_ID}")
message(STATUS "C++ compiler version: ${CMAKE_CXX_COMPILER_VERSION}")
message(STATUS "C++ compiler path: ${CMAKE_CXX_COMPILER}")
message(STATUS "C++ standard: ${CMAKE_CXX_STANDARD}")
message(STATUS "C++ extensions: ${CMAKE_CXX_EXTENSIONS}")

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

@ -149,15 +149,15 @@ You can then
<br>
The following flags (with their non-default values) can be passed to `cmake`:
| Flag | Description |
| --- | --- |
| `-DWITH_SERVER=1` | Build <kbd>Servatrice</kbd> server |
| `-DWITH_CLIENT=0` | Don't build <kbd>Cockatrice</kbd> client |
| `-DWITH_ORACLE=0` | Don't build <kbd>Oracle</kbd> card database tool |
| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode<br> Enables extra logging output, debug symbols, and much more verbose compiler warnings |
| `-DWARNING_AS_ERROR=0` | Don't treat compilation warnings as errors in debug mode |
| `-DUPDATE_TRANSLATIONS=1` | Configure `make` to update the translation .ts files for new strings in the source code<br> **Note:** `make clean` will remove the .ts files |
| `-DTEST=1` | Enable regression tests<br> **Note:** `make test` to run tests, *googletest* will be downloaded if not available |
| Flag | Description |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `-DWITH_SERVER=1` | Build <kbd>Servatrice</kbd> server |
| `-DWITH_CLIENT=0` | Don't build <kbd>Cockatrice</kbd> client |
| `-DWITH_ORACLE=0` | Don't build <kbd>Oracle</kbd> card database tool |
| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode<br> Enables extra logging output, debug symbols, and much more verbose compiler warnings |
| `-DWARNING_AS_ERROR=0` | Don't treat compilation warnings as errors in debug mode |
| `-DUPDATE_TRANSLATIONS=1` | Configure `make` to update the translation .ts files for new strings in the source code<br> **Note:** `make clean` will remove the .ts files |
| `-DTEST=1` | Enable regression tests<br> **Note:** `make test` to run tests, *googletest* will be downloaded if not available |
# Run

View file

@ -28,7 +28,7 @@ if(WITH_CLIENT)
)
endif()
if(WITH_ORACLE)
set(_ORACLE_NEEDED Concurrent Network Svg Widgets)
set(_ORACLE_NEEDED Concurrent Network Svg Widgets Xml)
endif()
if(TEST)
# Union of Qt modules required across all test targets (independent of application targets).

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

@ -166,6 +166,7 @@ set(cockatrice_SOURCES
src/interface/widgets/cards/additional_info/mana_cost_widget.cpp
src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp
src/interface/widgets/cards/art_crop_attribution.cpp
src/interface/widgets/cards/card_art_utils.cpp
src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp
src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp
src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp
@ -214,6 +215,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
@ -380,6 +382,7 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/tab_card_art_rules.cpp
src/interface/widgets/tabs/tab_deck_editor.cpp
src/interface/widgets/tabs/tab_deck_storage.cpp
src/interface/widgets/tabs/tab_developer.cpp
src/interface/widgets/tabs/tab_game.cpp
src/interface/widgets/tabs/tab_home.cpp
src/interface/widgets/tabs/tab_logs.cpp
@ -516,6 +519,8 @@ qt6_add_executable(
MANUAL_FINALIZATION
)
target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
qt6_add_shaders(
cockatrice
"onboarding_shaders"

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

@ -1,5 +1,6 @@
#include "remote_connection_controller.h"
#include "../../../interface/pixel_map_generator.h"
#include "../../settings/cache_settings.h"
#include "../interface/widgets/dialogs/dlg_connect.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h"
@ -180,7 +181,7 @@ void ConnectionController::onServerShutdownEvent(const Event_ServerShutdown &eve
"games will be lost.\nReason for shutdown: %1",
"", event.minutes())
.arg(QString::fromStdString(event.reason())));
serverShutdownMessageBox.setIconPixmap(QPixmap("theme:cockatrice").scaled(64, 64));
serverShutdownMessageBox.setIconPixmap(themePixmap(QStringLiteral("cockatrice")).scaled(64, 64));
serverShutdownMessageBox.setText(tr("Scheduled server shutdown"));
serverShutdownMessageBox.setWindowModality(Qt::ApplicationModal);
serverShutdownMessageBox.setVisible(true);

View file

@ -1,5 +1,6 @@
#include "filter_builder.h"
#include "../interface/pixel_map_generator.h"
#include "../interface/widgets/utility/custom_line_edit.h"
#include <QComboBox>
@ -21,7 +22,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent)
typeCombo->addItem(CardFilter::typeName(static_cast<CardFilter::Type>(i)), QVariant(i));
}
QPushButton *ok = new QPushButton(QPixmap("theme:icons/increment"), QString());
QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString());
ok->setObjectName("ok");
ok->setMaximumSize(20, 20);

View file

@ -13,12 +13,12 @@ CounterState *CounterState::fromProto(const ServerInfo_Counter &counter, QObject
convertColorToQColor(counter.counter_color()), counter.radius(), counter.count(), parent);
}
void CounterState::setValue(int newValue)
void CounterState::setValue(int newValue, bool skipDamageAnimation)
{
if (newValue == value) {
return;
}
int old = value;
value = newValue;
emit valueChanged(old, newValue);
emit valueChanged(old, newValue, skipDamageAnimation);
}

View file

@ -35,10 +35,23 @@ public:
return value;
}
void setValue(int newValue);
/**
* @brief Set the counter value.
* @param newValue The new value.
* @param skipDamageAnimation When true, valueChanged is emitted with skipDamageAnimation=true, letting views
* suppress damage-related feedback (e.g. battlefield shimmer, life counter flash) for values set during replay
* rewinds.
*/
void setValue(int newValue, bool skipDamageAnimation = false);
signals:
void valueChanged(int oldValue, int newValue);
/**
* @brief Emitted whenever the value changes.
* @param oldValue The previous value.
* @param newValue The new value.
* @param skipDamageAnimation True when the change should not trigger damage/life-change feedback in views.
*/
void valueChanged(int oldValue, int newValue, bool skipDamageAnimation);
private:
int id;

View file

@ -430,12 +430,13 @@ void GameEventHandler::eventJoin(const Event_Join &event, int /*eventPlayerId*/,
QString playerName = QString::fromStdString(playerInfo.user_info().name());
emit addPlayerToAutoCompleteList(playerName);
if (game->getPlayerManager()->getPlayers().contains(playerId)) {
PlayerManager *playerManager = game->getPlayerManager();
if (playerManager->getPlayers().contains(playerId) || playerManager->getSpectators().contains(playerId)) {
return;
}
if (playerInfo.spectator()) {
game->getPlayerManager()->addSpectator(playerId, playerInfo);
playerManager->addSpectator(playerId, playerInfo);
emit logJoinSpectator(playerName);
emit spectatorJoined(playerInfo);
} else {

View file

@ -13,7 +13,8 @@
enum EventProcessingOption
{
SKIP_REVEAL_WINDOW = 0x0001,
SKIP_TAP_ANIMATION = 0x0002
SKIP_TAP_ANIMATION = 0x0002,
SKIP_DAMAGE_ANIMATION = 0x0004
};
// Wrap it in a QFlags typedef

View file

@ -262,14 +262,15 @@ void PlayerEventHandler::eventCreateCounter(const Event_CreateCounter &event)
player->addCounter(event.counter_info());
}
void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event)
void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options)
{
CounterState *ctr = player->getCounters().value(event.counter_id(), nullptr);
if (!ctr) {
return;
}
int oldValue = ctr->getValue();
ctr->setValue(event.value());
const bool skipDamageAnimation = options.testFlag(SKIP_DAMAGE_ANIMATION);
ctr->setValue(event.value(), skipDamageAnimation);
emit logSetCounter(player, ctr->getName(), event.value(), oldValue);
}
@ -625,7 +626,7 @@ void PlayerEventHandler::processGameEvent(GameEvent::GameEventType type,
eventCreateCounter(event.GetExtension(Event_CreateCounter::ext));
break;
case GameEvent::SET_COUNTER:
eventSetCounter(event.GetExtension(Event_SetCounter::ext));
eventSetCounter(event.GetExtension(Event_SetCounter::ext), options);
break;
case GameEvent::DEL_COUNTER:
eventDelCounter(event.GetExtension(Event_DelCounter::ext));

View file

@ -153,7 +153,7 @@ public:
void eventCreateCounter(const Event_CreateCounter &event);
/// Set a player-level counter value.
void eventSetCounter(const Event_SetCounter &event);
void eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options);
/// Delete a player-level counter.
void eventDelCounter(const Event_DelCounter &event);

View file

@ -175,7 +175,15 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info)
const ServerInfo_Card &cardInfo = zoneInfo.card_list(j);
auto *card = new CardItem(this);
card->processCardInfo(cardInfo);
zone->addCard(card, false, cardInfo.x(), cardInfo.y());
// Zones without coordinates (hand, piles, stack) preserve the order
// they arrive in on the server in the positions of their cards list.
// The x coordinate of such cards is always 0, so inserting at it
// would reverse the list on reconnect. Append instead.
if (zoneInfo.with_coords()) {
zone->addCard(card, false, cardInfo.x(), cardInfo.y());
} else {
zone->addCard(card, false, -1);
}
}
}
if (zoneInfo.has_always_reveal_top_card()) {

View file

@ -75,6 +75,14 @@ PlayerLogic *PlayerManager::getPlayer(int playerId) const
return player;
}
void PlayerManager::clearSpectators()
{
const QList<int> spectatorIds = spectators.keys();
for (int spectatorId : spectatorIds) {
removeSpectator(spectatorId);
}
}
void PlayerManager::onPlayerConceded(int playerId, bool conceded)
{
// Everything else cares about this

View file

@ -100,6 +100,9 @@ public:
emit spectatorRemoved(spectatorId, spectatorInfo);
}
/** @brief Remove all spectators, emitting the removal signal for each. */
void clearSpectators();
[[nodiscard]] AbstractGame *getGame() const
{
return game;

View file

@ -29,9 +29,9 @@ AbstractCounter::AbstractCounter(CounterState *state,
{
setAcceptHoverEvents(true);
connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) {
connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue, bool skipDamageAnimation) {
value = newValue;
onValueChanged(oldValue, newValue);
onValueChanged(oldValue, newValue, skipDamageAnimation);
update();
});
@ -230,7 +230,7 @@ void AbstractCounterDialog::changeValue(int diff)
setTextValue(QString::number(curValue));
}
void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/)
void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/, bool /*skipDamageAnimation*/)
{
// Default: no feedback. Subclasses such as PlayerCounter override this to
// flash the counter on meaningful changes (life gain/loss).

View file

@ -39,8 +39,9 @@ protected:
* @brief Hook for subclasses that need per-value-change feedback (e.g. life-total flash).
*
* Called whenever the counter's value changes, before the item repaints.
* @param skipDamageAnimation True when damage-related feedback should be suppressed (replay rewinds).
*/
virtual void onValueChanged(int oldValue, int newValue);
virtual void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation);
void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override;

View file

@ -316,7 +316,7 @@ void CardItem::drawAttachArrow()
for (const auto &item : scene()->selectedItems()) {
CardItem *card = qgraphicsitem_cast<CardItem *>(item);
if (card == nullptr) {
if (card == nullptr || card == this) {
continue;
}
if (card->getZone() != state->getZone()) {

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

@ -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.
QObject::disconnect(nullptr, nullptr, this, nullptr);
// 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;
@ -216,7 +221,12 @@ void GameScene::removePlayer(PlayerLogic *player)
clearArrowsForPlayer(player->getPlayerInfo()->getId());
for (ZoneViewWidget *zone : zoneViews) {
// Closing a view removes it from zoneViews synchronously, so iterate over a
// copy: otherwise a player with several open views (e.g. library and hand)
// only has the first one closed here and the remaining views are left
// pointing at a player that is about to be deleted.
const QList<ZoneViewWidget *> zoneViewCopy = zoneViews;
for (ZoneViewWidget *zone : zoneViewCopy) {
if (zone->getPlayer() == player) {
zone->close();
}
@ -659,7 +669,10 @@ CardItem *GameScene::findTopmostCardInZone(const QList<QGraphicsItem *> &items,
*/
void GameScene::toggleZoneView(PlayerLogic *player, const QString &zoneName, int numberCards, bool isReversed)
{
for (auto &view : zoneViews) {
// Closing a view removes it from zoneViews synchronously, so iterate over a
// copy to make sure every already-open matching view is closed.
const QList<ZoneViewWidget *> zoneViewCopy = zoneViews;
for (auto *view : zoneViewCopy) {
ZoneViewZone *temp = view->getZone();
if (temp->getLogic()->getName() == zoneName && temp->getLogic()->getPlayer() == player &&
qobject_cast<ZoneViewZoneLogic *>(temp->getLogic())->getNumberCards() == numberCards) {
@ -777,8 +790,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 +817,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

@ -1,5 +1,6 @@
#include "hand_counter.h"
#include "../interface/pixel_map_generator.h"
#include "zones/card_zone.h"
#include <QGraphicsSceneMouseEvent>
@ -32,7 +33,8 @@ void HandCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*op
QSize translatedSize = painter->combinedTransform().mapRect(boundingRect()).size().toSize();
QPixmap cachedPixmap;
if (!QPixmapCache::find("handCounter" + QString::number(translatedSize.width()), &cachedPixmap)) {
cachedPixmap = QPixmap("theme:hand").scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
cachedPixmap =
themePixmap(QStringLiteral("hand")).scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
QPixmapCache::insert("handCounter" + QString::number(translatedSize.width()), cachedPixmap);
}
resetPainterTransform(painter);

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

@ -3,6 +3,7 @@
#include "../../game/player/player_actions.h"
#include "../../interface/card_picture_loader/card_picture_loader.h"
#include "../../interface/widgets/cards/art_crop_attribution.h"
#include "../../interface/widgets/cards/card_art_utils.h"
#include "../../interface/widgets/playmat/playmat_utils.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "../board/abstract_card_item.h"
@ -251,8 +252,8 @@ void PlayerGraphicsItem::onCounterAdded(CounterState *state)
AbstractCounter *widget;
if (state->getName() == "life") {
widget = playerTarget->addCounter(state);
connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) {
if (newValue < oldValue) {
connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue, bool skipDamageAnimation) {
if (newValue < oldValue && !skipDamageAnimation) {
tableZoneGraphicsItem->triggerDamageShimmer();
}
});
@ -442,7 +443,7 @@ void PlayerGraphicsItem::updatePlaymat()
hasPlaymat = true;
emit playmatChanged(true);
}
playmatPixmap = fullRes;
playmatPixmap = CardArtUtils::rotateSidewaysLayoutArt(fullRes, card);
update();
}

View file

@ -53,13 +53,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor,
QWidget *parent)
: QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false)
{
readyIcon = QPixmap("theme:icons/ready_start");
notReadyIcon = QPixmap("theme:icons/not_ready_start");
concededIcon = QPixmap("theme:icons/conceded");
readyIcon = themePixmap(QStringLiteral("icons/ready_start"));
notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start"));
concededIcon = themePixmap(QStringLiteral("icons/conceded"));
playerIcon = loadColorAdjustedPixmap("theme:icons/player");
judgeIcon = loadColorAdjustedPixmap("theme:icons/scales");
spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator");
lockIcon = QPixmap("theme:icons/lock");
lockIcon = themePixmap(QStringLiteral("icons/lock"));
if (tabSupervisor) {
itemDelegate = new PlayerListItemDelegate(this);
@ -92,6 +92,11 @@ void PlayerListWidget::retranslateUi()
void PlayerListWidget::addPlayer(const ServerInfo_PlayerProperties &player)
{
if (players.contains(player.player_id())) {
updatePlayerProperties(player);
return;
}
QTreeWidgetItem *newPlayer = new PlayerListTWI;
players.insert(player.player_id(), newPlayer);
updatePlayerProperties(player);
@ -176,6 +181,17 @@ void PlayerListWidget::removePlayer(int playerId)
delete takeTopLevelItem(indexOfTopLevelItem(player));
}
void PlayerListWidget::clearSpectators()
{
const QList<int> playerIds = players.keys();
for (int playerId : playerIds) {
QTreeWidgetItem *player = players.value(playerId, 0);
if (player && !player->data(1, Qt::UserRole).toBool()) {
removePlayer(playerId);
}
}
}
void PlayerListWidget::setActivePlayer(int playerId)
{
QMapIterator<int, QTreeWidgetItem *> i(players);

View file

@ -66,6 +66,7 @@ public slots:
void addPlayer(const ServerInfo_PlayerProperties &player);
void removePlayer(int playerId);
void updatePlayerProperties(const ServerInfo_PlayerProperties &prop, int playerId = -1);
void clearSpectators();
};
#endif

View file

@ -69,7 +69,7 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*
}
}
void PlayerCounter::onValueChanged(int oldValue, int newValue)
void PlayerCounter::onValueChanged(int oldValue, int newValue, bool skipDamageAnimation)
{
flashDelta = newValue - oldValue;
if (flashDelta == 0) {
@ -81,6 +81,11 @@ void PlayerCounter::onValueChanged(int oldValue, int newValue)
return;
}
if (skipDamageAnimation) {
flashAlpha = 0.0;
return;
}
flashAlpha = 1.0;
flashClock.start();
if (scene()) {
@ -132,8 +137,18 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o
QRectF translatedRect = painter->combinedTransform().mapRect(avatarBoundingRect);
QSize translatedSize = translatedRect.size().toSize();
QPixmap cachedPixmap;
// The key must cover everything the generated pawn depends on: the rendered
// size, the user level, and the pixmap being drawn. fullPixmap.cacheKey() is
// 0 for every null pixmap, so the default-pawn branch additionally needs the
// pawn's privlevel (lowercased, matching UserLevelPixmapGenerator) and colors
// in the key — otherwise two players without a custom avatar (and the same
// user level) would share one cached pawn.
const QString cacheKey = "avatar" + QString::number(translatedSize.width()) + "_" +
QString::number(info->user_level()) + "_" + QString::number(fullPixmap.cacheKey());
QString::number(translatedSize.height()) + "_" + QString::number(info->user_level()) +
"_" + QString::number(fullPixmap.cacheKey()) + "_" +
QString::fromStdString(info->privlevel()).toLower() + "_" +
QString::fromStdString(info->pawn_colors().left_side()) + "_" +
QString::fromStdString(info->pawn_colors().right_side());
if (!QPixmapCache::find(cacheKey, &cachedPixmap)) {
cachedPixmap = QPixmap(translatedSize.width(), translatedSize.height());

View file

@ -21,7 +21,7 @@ class PlayerCounter : public AbstractCounter, public IAnimatedItem
{
Q_OBJECT
protected:
void onValueChanged(int oldValue, int newValue) override;
void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation) override;
private:
static constexpr qreal flashDurationMs = 450.0;

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

@ -62,7 +62,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)"));
searchEdit.setClearButtonEnabled(true);
searchEdit.addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
auto help = searchEdit.addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition);
auto help = searchEdit.addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition);
connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); });
@ -549,7 +549,7 @@ void ZoneViewWidget::initStyleOption(QStyleOption *option) const
{
QStyleOptionTitleBar *titleBar = qstyleoption_cast<QStyleOptionTitleBar *>(option);
if (titleBar) {
titleBar->icon = QPixmap("theme:cockatrice");
titleBar->icon = themePixmap(QStringLiteral("cockatrice"));
}
}

View file

@ -1,6 +1,7 @@
#include "card_picture_loader.h"
#include "../../client/settings/cache_settings.h"
#include "../pixel_map_generator.h"
#include "card_picture_loader_cache_method.h"
#include "card_picture_loader_local_schemes.h"
@ -62,7 +63,7 @@ void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size)
QString backCacheKey = "_trice_card_back_" + QString::number(size.width()) + "x" + QString::number(size.height());
if (!QPixmapCache::find(backCacheKey, &pixmap)) {
qCDebug(CardPictureLoaderLog) << "PictureLoader: cache miss for" << backCacheKey;
QPixmap tmpPixmap("theme:cardback");
QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback"));
if (tmpPixmap.isNull()) {
qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback'! Using fallback pixmap.";
@ -83,7 +84,7 @@ void CardPictureLoader::getCardBackLoadingInProgressPixmap(QPixmap &pixmap, QSiz
"_trice_card_back_inprogress_" + QString::number(size.width()) + "x" + QString::number(size.height());
if (!QPixmapCache::find(backCacheKey, &pixmap)) {
qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey;
QPixmap tmpPixmap("theme:cardback");
QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback"));
if (tmpPixmap.isNull()) {
qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for in-progress state! Using fallback.";
@ -105,7 +106,7 @@ void CardPictureLoader::getCardBackLoadingFailedPixmap(QPixmap &pixmap, QSize si
"_trice_card_back_failed_" + QString::number(size.width()) + "x" + QString::number(size.height());
if (!QPixmapCache::find(backCacheKey, &pixmap)) {
qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey;
QPixmap tmpPixmap("theme:cardback");
QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback"));
if (tmpPixmap.isNull()) {
qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for failed state! Using fallback.";
@ -138,7 +139,8 @@ void CardPictureLoader::getPixmap(QPixmap &pixmap, const ExactCard &card, QSize
QPixmap bigPixmap;
if (QPixmapCache::find(key, &bigPixmap)) {
if (bigPixmap.isNull()) {
getCardBackLoadingFailedPixmap(pixmap, size);
// Leave the pixmap null so callers fall back to a solid color
// instead of showing the card back.
QDateTime failedAtTime = getInstance().failedAt.value(key);
if (!failedAtTime.isValid() ||
failedAtTime.addSecs(RETRY_FAILED_CARDS_SECS) < QDateTime::currentDateTime()) {

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

@ -1,5 +1,7 @@
#include "pixel_map_generator.h"
#include "theme_manager.h"
#include <QApplication>
#include <QDomDocument>
#include <QFile>
@ -14,6 +16,7 @@
#define DEFAULT_COLOR_MODERATOR_LEFT "#ffffff";
#define DEFAULT_COLOR_MODERATOR_RIGHT "#000000";
#define DEFAULT_COLOR_ADMIN "#ff2701";
#define DEFAULT_COLOR_DEVELOPER "#B8B8B8"
/**
* Clamps an svg render size so that rendering does not exceed a multiple of the requested size.
@ -82,7 +85,13 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl
/**
* Try to load path image from non-SVG formats, otherwise fall back to SVG.
* This is to allow custom themes to support non-SVG format type overrides, since SVG requires custom loading.
* @param path The path to the file, with no file extension. File formats will be automatically detected.
*
* The path may already carry the resolved file extension (e.g. via
* ThemeManager::assetPath); such paths are loaded directly. Otherwise a
* format-agnostic lookup probes png, jpg and finally svg.
*
* @param path The path to the file, with no file extension unless the caller
* already resolved it. File formats will be automatically detected.
* @param size The desired size of the pixmap.
* @param expandOnly If true, then keep the size of the initial pixmap to at least the size (Only relevant if SVG).
*
@ -90,6 +99,19 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl
*/
static QPixmap tryLoadImage(const QString &path, const QSize &size, bool expandOnly = false)
{
if (path.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)) {
return loadSvg(path, size, expandOnly);
}
if (path.endsWith(QLatin1String(".png"), Qt::CaseInsensitive) ||
path.endsWith(QLatin1String(".jpg"), Qt::CaseInsensitive) ||
path.endsWith(QLatin1String(".jpeg"), Qt::CaseInsensitive)) {
QPixmap pix(path);
if (!pix.isNull()) {
return pix.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
}
return {};
}
const auto formats = {"png", "jpg"};
QPixmap returnPixmap;
@ -111,7 +133,8 @@ QPixmap PhasePixmapGenerator::generatePixmap(int height, QString name)
return pmCache.value(key);
}
QPixmap pixmap = tryLoadImage("theme:phases/" + name, QSize(height, height));
QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("phases/") + name),
QSize(height, height));
pmCache.insert(key, pixmap);
return pixmap;
@ -359,6 +382,8 @@ QIcon UserLevelPixmapGenerator::generateIconDefault(int height,
if (userLevel.testFlag(ServerInfo_User::IsAdmin)) {
colorLeft = DEFAULT_COLOR_ADMIN;
} else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) {
colorLeft = DEFAULT_COLOR_DEVELOPER;
} else if (userLevel.testFlag(ServerInfo_User::IsModerator)) {
colorLeft = DEFAULT_COLOR_MODERATOR_LEFT;
colorRight = DEFAULT_COLOR_MODERATOR_RIGHT;
@ -396,7 +421,8 @@ QPixmap LockPixmapGenerator::generatePixmap(int height)
return pmCache.value(key);
}
QPixmap pixmap = tryLoadImage("theme:icons/lock", QSize(height, height), true);
QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")),
QSize(height, height), true);
pmCache.insert(key, pixmap);
return pixmap;
}
@ -411,7 +437,8 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded)
}
QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed";
QPixmap pixmap = tryLoadImage("theme:icons/" + name, QSize(height, height), true);
QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/") + name),
QSize(height, height), true);
pmCache.insert(key, pixmap);
return pixmap;
@ -472,6 +499,13 @@ QHash<QString, QPixmap> ManaSymbolPixmapGenerator::scaledCache;
QPixmap loadColorAdjustedPixmap(const QString &name)
{
// Prefer an authored scheme-qualified variant when one exists for this asset.
const QString variant = themeManager->schemeVariantPath(QStringView(name).mid(QStringLiteral("theme:").size()));
if (!variant.isEmpty()) {
return QPixmap(QStringLiteral("theme:") + variant);
}
// Legacy fallback: runtime-invert for dark mode when no authored variant.
if (qApp->palette().windowText().color().lightness() > 200) {
QImage img(name);
img.invertPixels();
@ -482,3 +516,21 @@ QPixmap loadColorAdjustedPixmap(const QString &name)
return QPixmap(name);
}
}
QPixmap themePixmap(QStringView prefix)
{
const QString resolved = themeManager->assetPath(prefix);
return QPixmap(QStringLiteral("theme:") + resolved);
}
void clearPixmapGeneratorCaches()
{
PhasePixmapGenerator::clear();
CounterPixmapGenerator::clear();
PingPixmapGenerator::clear();
CountryPixmapGenerator::clear();
UserLevelPixmapGenerator::clear();
LockPixmapGenerator::clear();
DropdownIconPixmapGenerator::clear();
ManaSymbolPixmapGenerator::clear();
}

View file

@ -156,4 +156,15 @@ public:
QPixmap loadColorAdjustedPixmap(const QString &name);
// Loads a "theme:" asset (with no file extension in prefix), preferring the
// scheme-qualified variant (prefix-dark / prefix-light, resolved via
// ThemeManager::assetPath) and falling back to the plain asset. Callers load
// the returned path directly. Use for scheme-sensitive pixmaps like
// backgrounds, the card back, and the app logo.
QPixmap themePixmap(QStringView prefix);
// Clears every PixmapGenerator's static cache so scheme variants are
// re-resolved when the active theme or color scheme changes.
void clearPixmapGeneratorCaches();
#endif

View file

@ -1,10 +1,12 @@
#include "theme_manager.h"
#include "../../client/settings/cache_settings.h"
#include "pixel_map_generator.h"
#include <QApplication>
#include <QColor>
#include <QDebug>
#include <QFileInfo>
#include <QLibraryInfo>
#include <QMap>
#include <QMetaEnum>
@ -140,6 +142,48 @@ bool ThemeManager::isDarkMode(const QString &themeDirPath) const
}
}
QString ThemeManager::schemeVariantPath(QStringView prefix) const
{
static const QStringList formats = {QStringLiteral(".png"), QStringLiteral(".jpg"), QStringLiteral(".jpeg"),
QStringLiteral(".svg")};
const QString scheme = isDarkMode(currentThemePath) ? QStringLiteral("dark") : QStringLiteral("light");
const QString variantStem = prefix.toString() + QLatin1Char('-') + scheme;
for (const QString &format : formats) {
if (QFileInfo::exists(QStringLiteral("theme:") + variantStem + format)) {
return variantStem + format;
}
}
return QString();
}
QString ThemeManager::assetPath(QStringView prefix) const
{
// Probe order mirrors tryLoadImage: a theme may override the default SVG
// with a raster of the same stem, so raster wins over SVG within a stem.
static const QStringList formats = {QStringLiteral(".png"), QStringLiteral(".jpg"), QStringLiteral(".jpeg"),
QStringLiteral(".svg")};
auto findExisting = [](const QString &stem) {
for (const QString &format : formats) {
if (QFileInfo::exists(QStringLiteral("theme:") + stem + format)) {
return stem + format;
}
}
return QString();
};
// Prefer the scheme-qualified variant when it exists, else the plain
// asset as the super fallback. Both return the resolved path including
// its file extension so callers can load it directly.
const QString variant = schemeVariantPath(prefix);
if (!variant.isEmpty()) {
return variant;
}
const QString resolvedPlain = findExisting(prefix.toString());
return resolvedPlain.isEmpty() ? prefix.toString() : resolvedPlain;
}
bool ThemeManager::isBuiltInTheme()
{
const auto themeName = SettingsCache::instance().getThemeName();
@ -195,7 +239,7 @@ QStringMap &ThemeManager::getAvailableThemes()
QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor)
{
QBrush brush;
QPixmap tmp = QPixmap("theme:zones/" + fileName);
QPixmap tmp = QPixmap("theme:" + assetPath(QStringLiteral("zones/") + fileName));
if (tmp.isNull()) {
brush.setColor(fallbackColor);
brush.setStyle(Qt::SolidPattern);
@ -209,7 +253,7 @@ QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor)
QBrush ThemeManager::loadExtraBrush(QString fileName, QBrush &fallbackBrush)
{
QBrush brush;
QPixmap tmp = QPixmap("theme:zones/" + fileName);
QPixmap tmp = QPixmap("theme:" + assetPath(QStringLiteral("zones/") + fileName));
if (tmp.isNull()) {
brush = fallbackBrush;
@ -393,9 +437,19 @@ void ThemeManager::themeChangedSlot()
currentThemePath = dirPath;
QDir dir(dirPath);
// CSS
if (!dirPath.isEmpty() && dir.exists(STYLE_CSS_NAME)) {
qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME));
// CSS — prefer the scheme-qualified stylesheet (style-dark.css /
// style-light.css) when present, else the plain style.css as fallback.
if (!dirPath.isEmpty()) {
const QString scheme = isDarkMode(dirPath) ? QStringLiteral("dark") : QStringLiteral("light");
const QString schemeCss = QFileInfo(QStringLiteral(STYLE_CSS_NAME)).completeBaseName() + QLatin1Char('-') +
scheme + QStringLiteral(".css");
if (dir.exists(schemeCss)) {
qApp->setStyleSheet("file:///" + dir.absoluteFilePath(schemeCss));
} else if (dir.exists(STYLE_CSS_NAME)) {
qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME));
} else {
qApp->setStyleSheet("");
}
} else {
qApp->setStyleSheet("");
}
@ -446,6 +500,7 @@ void ThemeManager::themeChangedSlot()
}
QPixmapCache::clear();
clearPixmapGeneratorCaches();
emit themeChanged();
}

View file

@ -87,6 +87,20 @@ public:
// Load/save per-scheme palette colors
static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme);
static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg);
// Resolve prefix to a scheme-qualified "theme:" path. Existence is probed
// internally across the formats themes may ship (.png/.jpg/.svg), so
// callers load the returned path directly. Prefers "<prefix>-<dark|light>"
// when a file exists at that stem, otherwise the plain "<prefix>" as the
// super fallback. The resolved scheme covers explicit light/dark as well
// as OS-resolved "system". Returns the path with its file extension when a
// match is found; unqualified assets keep working unchanged.
QString assetPath(QStringView prefix) const;
// Like assetPath, but resolves only the scheme-qualified variant
// ("<prefix>-<dark|light>.<ext>") and returns an empty string when no
// variant exists — it never falls back to the plain "<prefix>" asset.
// Callers that must distinguish "no authored variant" (e.g. to keep a
// legacy runtime fallback alive) should use this instead of assetPath.
QString schemeVariantPath(QStringView prefix) const;
// Load the theme's shipped default palette, falling back to the system
// theme directory when it is absent from the resolved (user) directory.
static PaletteConfig

View file

@ -0,0 +1,18 @@
#include "card_art_utils.h"
#include <QTransform>
#include <libcockatrice/card/printing/exact_card.h>
namespace CardArtUtils
{
QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card)
{
if (!card.getInfo().getUiAttributes().landscapeOrientation) {
return art;
}
QTransform transform;
transform.rotate(90);
return art.transformed(transform, Qt::SmoothTransformation);
}
} // namespace CardArtUtils

View file

@ -0,0 +1,25 @@
#ifndef CARD_ART_UTILS_H
#define CARD_ART_UTILS_H
#include <QPixmap>
class ExactCard;
namespace CardArtUtils
{
/**
* @brief Rotates a card's art upright when its layout shows sideways.
*
* Sideways-layout cards (planes, sieges/battles, split cards) store their
* landscape artwork rotated 90° inside a portrait frame. Art-crop displays,
* playmat art, and the card-info picture must show such art upright before
* sampling or painting. Portrait cards are returned unchanged.
*
* @param art The card pixmap to orient.
* @param card The card describing the art orientation.
* @return @p art rotated 90° clockwise for sideways-layout cards, else @p art.
*/
QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card);
} // namespace CardArtUtils
#endif // CARD_ART_UTILS_H

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 "../../../interface/card_picture_loader/card_picture_loader.h"
#include "../../../interface/widgets/tabs/tab_supervisor.h"
#include "../../window_main.h"
#include "card_art_utils.h"
#include <QMenu>
#include <QMouseEvent>
@ -193,12 +194,7 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event)
QPixmap transformedPixmap = resizedPixmap; // Default pixmap
if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) {
if (exactCard.getInfo().getUiAttributes().landscapeOrientation) {
// Rotate pixmap 90 degrees to the left
QTransform transform;
transform.rotate(90);
transformedPixmap = resizedPixmap.transformed(transform, Qt::SmoothTransformation);
}
transformedPixmap = CardArtUtils::rotateSidewaysLayoutArt(resizedPixmap, exactCard);
}
// Handle DPI scaling

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

@ -1,5 +1,6 @@
#include "abstract_analytics_panel_widget.h"
#include "../../pixel_map_generator.h"
#include "deck_list_statistics_analyzer.h"
#include <QPushButton>
@ -20,7 +21,7 @@ AbstractAnalyticsPanelWidget::AbstractAnalyticsPanelWidget(QWidget *parent, Deck
// config button
configureButton = new QPushButton(this);
configureButton->setIcon(QPixmap("theme:icons/cogwheel"));
configureButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel")));
configureButton->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
connect(configureButton, &QPushButton::clicked, this, &AbstractAnalyticsPanelWidget::applyConfigFromDialog);
bannerAndSettingsLayout->addWidget(configureButton, 0);

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

@ -28,7 +28,7 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent
searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)"));
searchEdit->setClearButtonEnabled(true);
searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
auto help = searchEdit->addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition);
auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition);
setFocusProxy(searchEdit);
setFocusPolicy(Qt::ClickFocus);
@ -59,13 +59,13 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent
&DeckEditorDatabaseDisplayWidget::onRelatedCardClicked);
aAddCard = new QAction(QString(), this);
aAddCard->setIcon(QPixmap("theme:icons/arrow_right_green"));
aAddCard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green")));
connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck);
auto *tbAddCard = new QToolButton(this);
tbAddCard->setDefaultAction(aAddCard);
aAddCardToSideboard = new QAction(QString(), this);
aAddCardToSideboard->setIcon(QPixmap("theme:icons/arrow_right_blue"));
aAddCardToSideboard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_blue")));
connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard);
auto *tbAddCardToSideboard = new QToolButton(this);
tbAddCardToSideboard->setDefaultAction(aAddCardToSideboard);

View file

@ -2,20 +2,24 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../pixel_map_generator.h"
#include "../playmat/playmat_settings_dialog.h"
#include "../settings_page/user_interface_settings_page.h"
#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>
@ -189,25 +193,25 @@ void DeckEditorDeckDockWidget::createDeckDock()
&DeckEditorDeckDockWidget::applyActiveGroupCriteria);
aIncrement = new QAction(QString(), this);
aIncrement->setIcon(QPixmap("theme:icons/increment"));
aIncrement->setIcon(themePixmap(QStringLiteral("icons/increment")));
connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection);
auto *tbIncrement = new QToolButton(this);
tbIncrement->setDefaultAction(aIncrement);
aDecrement = new QAction(QString(), this);
aDecrement->setIcon(QPixmap("theme:icons/decrement"));
aDecrement->setIcon(themePixmap(QStringLiteral("icons/decrement")));
connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection);
auto *tbDecrement = new QToolButton(this);
tbDecrement->setDefaultAction(aDecrement);
aRemoveCard = new QAction(QString(), this);
aRemoveCard->setIcon(QPixmap("theme:icons/remove_row"));
aRemoveCard->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard);
auto *tbRemoveCard = new QToolButton(this);
tbRemoveCard->setDefaultAction(aRemoveCard);
aSwapCard = new QAction(QString(), this);
aSwapCard->setIcon(QPixmap("theme:icons/swap"));
aSwapCard->setIcon(themePixmap(QStringLiteral("icons/swap")));
connect(aSwapCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actSwapSelection);
auto *tbSwapCard = new QToolButton(this);
tbSwapCard->setDefaultAction(aSwapCard);
@ -772,14 +776,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

@ -19,6 +19,7 @@
#include <QComboBox>
#include <QDockWidget>
#include <QLabel>
#include <QMenu>
#include <QPushButton>
#include <QTextEdit>
#include <QTreeView>
@ -102,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

@ -4,6 +4,7 @@
#include "../../../client/settings/shortcuts_settings.h"
#include "../../../filters/filter_builder.h"
#include "../../../filters/filter_tree_model.h"
#include "../../pixel_map_generator.h"
#include <QGridLayout>
#include <QMenu>
@ -42,11 +43,11 @@ void DeckEditorFilterDockWidget::createFiltersDock()
connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter);
aClearFilterOne = new QAction(QString(), this);
aClearFilterOne->setIcon(QPixmap("theme:icons/decrement"));
aClearFilterOne->setIcon(themePixmap(QStringLiteral("icons/decrement")));
connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne);
aClearFilterAll = new QAction(QString(), this);
aClearFilterAll->setIcon(QPixmap("theme:icons/clearsearch"));
aClearFilterAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch")));
connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll);
auto *filterDelOne = new QToolButton();

View file

@ -1,5 +1,6 @@
#include "deck_list_history_manager_widget.h"
#include "../../pixel_map_generator.h"
#include "deck_state_manager.h"
DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_deckStateManager,
@ -10,7 +11,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de
layout = new QHBoxLayout(this);
aUndo = new QAction(QString(), this);
aUndo->setIcon(QPixmap("theme:icons/arrow_undo"));
aUndo->setIcon(themePixmap(QStringLiteral("icons/arrow_undo")));
aUndo->setShortcut(QKeySequence::Undo);
aUndo->setShortcutContext(Qt::ApplicationShortcut);
connect(aUndo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doUndo);
@ -19,7 +20,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de
undoButton->setDefaultAction(aUndo);
aRedo = new QAction(QString(), this);
aRedo->setIcon(QPixmap("theme:icons/arrow_redo"));
aRedo->setIcon(themePixmap(QStringLiteral("icons/arrow_redo")));
aRedo->setShortcut(QKeySequence::Redo);
aRedo->setShortcutContext(Qt::ApplicationShortcut);
connect(aRedo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doRedo);
@ -31,7 +32,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de
layout->addWidget(redoButton);
historyButton = new SettingsButtonWidget(this);
historyButton->setButtonIcon(QPixmap("theme:icons/arrow_history"));
historyButton->setButtonIcon(themePixmap(QStringLiteral("icons/arrow_history")));
historyLabel = new QLabel(this);

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,6 +1,7 @@
#include "dlg_connect.h"
#include "../../../client/settings/cache_settings.h"
#include "../../pixel_map_generator.h"
#include <QCheckBox>
#include <QComboBox>
@ -21,7 +22,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
previousHosts = new QComboBox(this);
btnDeleteServer = new QPushButton(this);
btnDeleteServer->setIcon(QPixmap("theme:icons/remove_row"));
btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
btnDeleteServer->setFixedWidth(30);
@ -29,7 +30,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
hps = new HandlePublicServers(this);
btnRefreshServers = new QPushButton(this);
btnRefreshServers->setIcon(QPixmap("theme:icons/sync"));
btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync")));
btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers"));
btnRefreshServers->setFixedWidth(30);
@ -99,7 +100,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
updateDisplayInfo(previousHosts->currentText());
btnForgotPassword = new QPushButton(this);
btnForgotPassword->setIcon(QPixmap("theme:icons/forgot_password"));
btnForgotPassword->setIcon(themePixmap(QStringLiteral("icons/forgot_password")));
btnForgotPassword->setToolTip(tr("Reset Password"));
btnForgotPassword->setFixedWidth(30);
connect(btnForgotPassword, &QPushButton::released, this, &DlgConnect::actForgotPassword);

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,5 +1,6 @@
#include "dlg_edit_tokens.h"
#include "../../pixel_map_generator.h"
#include "../interface/widgets/utility/get_text_with_max.h"
#include <QAction>
@ -90,10 +91,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul
&DlgEditTokens::tokenSelectionChanged);
QAction *aAddToken = new QAction(tr("Add token"), this);
aAddToken->setIcon(QPixmap("theme:icons/increment"));
aAddToken->setIcon(themePixmap(QStringLiteral("icons/increment")));
connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken);
QAction *aRemoveToken = new QAction(tr("Remove token"), this);
aRemoveToken->setIcon(QPixmap("theme:icons/decrement"));
aRemoveToken->setIcon(themePixmap(QStringLiteral("icons/decrement")));
connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken);
auto *databaseToolBar = new QToolBar;

View file

@ -1,6 +1,7 @@
#include "dlg_manage_sets.h"
#include "../../../client/settings/cache_settings.h"
#include "../../pixel_map_generator.h"
#include "../interface/card_picture_loader/card_picture_loader.h"
#include "../interface/widgets/utility/custom_line_edit.h"
@ -35,28 +36,28 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
setsEditToolBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
aTop = new QAction(QString(), this);
aTop->setIcon(QPixmap("theme:icons/arrow_top_green"));
aTop->setIcon(themePixmap(QStringLiteral("icons/arrow_top_green")));
aTop->setToolTip(tr("Move selected set to the top"));
aTop->setEnabled(false);
connect(aTop, &QAction::triggered, this, &WndSets::actTop);
setsEditToolBar->addAction(aTop);
aUp = new QAction(QString(), this);
aUp->setIcon(QPixmap("theme:icons/arrow_up_green"));
aUp->setIcon(themePixmap(QStringLiteral("icons/arrow_up_green")));
aUp->setToolTip(tr("Move selected set up"));
aUp->setEnabled(false);
connect(aUp, &QAction::triggered, this, &WndSets::actUp);
setsEditToolBar->addAction(aUp);
aDown = new QAction(QString(), this);
aDown->setIcon(QPixmap("theme:icons/arrow_down_green"));
aDown->setIcon(themePixmap(QStringLiteral("icons/arrow_down_green")));
aDown->setToolTip(tr("Move selected set down"));
aDown->setEnabled(false);
connect(aDown, &QAction::triggered, this, &WndSets::actDown);
setsEditToolBar->addAction(aDown);
aBottom = new QAction(QString(), this);
aBottom->setIcon(QPixmap("theme:icons/arrow_bottom_green"));
aBottom->setIcon(themePixmap(QStringLiteral("icons/arrow_bottom_green")));
aBottom->setToolTip(tr("Move selected set to the bottom"));
aBottom->setEnabled(false);
connect(aBottom, &QAction::triggered, this, &WndSets::actBottom);
@ -66,7 +67,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
searchField = new LineEditUnfocusable;
searchField->setObjectName("searchEdit");
searchField->setPlaceholderText(tr("Search by set name, code, type, or release date"));
searchField->addAction(QPixmap("theme:icons/search"), LineEditUnfocusable::LeadingPosition);
searchField->addAction(themePixmap(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition);
searchField->setClearButtonEnabled(true);
setFocusProxy(searchField);

View file

@ -1,6 +1,7 @@
#include "dlg_register.h"
#include "../../../client/settings/cache_settings.h"
#include "../../pixel_map_generator.h"
#include "../server/handle_public_servers.h"
#include "../server/user/user_info_connection.h"
@ -24,7 +25,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
previousHosts = new QComboBox(this);
btnDeleteServer = new QPushButton(this);
btnDeleteServer->setIcon(QPixmap("theme:icons/remove_row"));
btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
btnDeleteServer->setFixedWidth(30);
@ -32,7 +33,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
hps = new HandlePublicServers(this);
btnRefreshServers = new QPushButton(this);
btnRefreshServers->setIcon(QPixmap("theme:icons/sync"));
btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync")));
btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers"));
btnRefreshServers->setFixedWidth(30);

View file

@ -6,6 +6,7 @@
#include "dlg_settings.h"
#include "../../../client/settings/cache_settings.h"
#include "../../pixel_map_generator.h"
#include "../main.h"
#include "../settings_page/appearance_settings_page.h"
#include "../settings_page/deck_editor_settings_page.h"
@ -96,7 +97,7 @@ void DlgSettings::setupUi()
// Search bar
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
searchEdit->addAction(QPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
searchEdit->installEventFilter(this);
connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged);
@ -132,7 +133,7 @@ void DlgSettings::setupUi()
pagesWidget->addWidget(makeScrollable(userInterfacePage));
pagesWidget->addWidget(makeScrollable(deckEditorPage));
pagesWidget->addWidget(makeScrollable(storagePage));
pagesWidget->addWidget(messagesPage);
pagesWidget->addWidget(makeScrollable(messagesPage));
pagesWidget->addWidget(soundPage);
pagesWidget->addWidget(shortcutsPage);

View file

@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../interface/widgets/tabs/tab_supervisor.h"
#include "../../pixel_map_generator.h"
#include "../../theme_manager.h"
#include "../../window_main.h"
#include "../cards/art_crop_attribution.h"
@ -20,7 +21,8 @@
#include <libcockatrice/settings/paths_settings.h>
HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
: QWidget(parent), tabSupervisor(_tabSupervisor), background("theme:backgrounds/home"), overlay("theme:cockatrice")
: QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))),
overlay(themePixmap(QStringLiteral("cockatrice")))
{
layout = new QGridLayout(this);
@ -56,6 +58,9 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
&HomeWidget::initializeBackgroundFromSource);
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this,
&HomeWidget::updateButtonsToBackgroundColor);
// Scheme flips (light/dark/system with an OS switch) fire on themeManager,
// not on SettingsCache::themeChanged, so re-resolve the variant background.
connect(themeManager, &ThemeManager::themeChanged, this, &HomeWidget::initializeBackgroundFromSource);
connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this,
&HomeWidget::updateButtonsToBackgroundColor);
}
@ -74,7 +79,7 @@ void HomeWidget::initializeBackgroundFromSource()
switch (backgroundSourceType) {
case BackgroundSources::Theme:
cardChangeTimer->stop();
background = QPixmap("theme:backgrounds/home");
background = themePixmap(QStringLiteral("backgrounds/home"));
backgroundSourceDeck = DeckList();
backgroundSourceCard->setCard(ExactCard());
updateButtonsToBackgroundColor();

View file

@ -182,6 +182,13 @@ void FirstRunWizard::onCardDatabaseUpdateFinished(bool success)
}
}
void FirstRunWizard::onCardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total)
{
if (cardDatabasePage) {
cardDatabasePage->onUpdateProgress(stage, done, total);
}
}
void FirstRunWizard::finish()
{
accept();

View file

@ -37,6 +37,9 @@ public slots:
/** @brief Forwarded from MainWindow once the background card database update process exits. */
void onCardDatabaseUpdateFinished(bool success);
/** @brief Forwarded from MainWindow while the background card database update process runs. */
void onCardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total);
protected:
void closeEvent(QCloseEvent *event) override;
void changeEvent(QEvent *event) override;

View file

@ -15,6 +15,7 @@
#include <QTimer>
#include <QUrl>
#include <QVBoxLayout>
#include <climits>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/updates_settings.h>
@ -179,6 +180,25 @@ void CardDatabaseSetupPage::onUpdateFinished(bool success)
}
}
void CardDatabaseSetupPage::onUpdateProgress(const QString &stage, qint64 done, qint64 total)
{
if (state != State::Running) {
return;
}
progressBar->setRange(0, total > 0 ? static_cast<int>(qMin<qint64>(total, INT_MAX)) : 0);
progressBar->setValue(static_cast<int>(qMin<qint64>(done, INT_MAX)));
if (total > 0) {
const int percent = static_cast<int>((100.0 * done) / total);
if (stage == QLatin1String("download")) {
statusLabel->setText(tr("Downloading the card database (%1%)…").arg(percent));
} else if (stage == QLatin1String("scan")) {
statusLabel->setText(tr("Parsing the card database (%1%)…").arg(percent));
} else if (stage == QLatin1String("import")) {
statusLabel->setText(tr("Importing cards (%1%)…").arg(percent));
}
}
}
QString CardDatabaseSetupPage::nextButtonText() const
{
return state == State::NotStarted ? tr("Download") : QString();

View file

@ -30,6 +30,7 @@ public:
void retranslateUi() override;
void onUpdateFinished(bool success);
void onUpdateProgress(const QString &stage, qint64 done, qint64 total);
signals:
void updateRequested();

View file

@ -2,6 +2,7 @@
#include "../../card_picture_loader/card_picture_loader.h"
#include "../cards/art_crop_attribution.h"
#include "../cards/card_art_utils.h"
#include "../utility/completer_utils.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
@ -276,7 +277,7 @@ void PlaymatSettingsDialog::reloadPreview()
return;
}
currentPixmap = fullRes;
currentPixmap = CardArtUtils::rotateSidewaysLayoutArt(fullRes, card);
preview->setPixmap(currentPixmap);
preview->setParams(currentParams);
preview->setAttribution(buildArtAttribution(card));

View file

@ -1,5 +1,7 @@
#include "settings_button_widget.h"
#include "../../pixel_map_generator.h"
#include <QApplication>
#include <QHBoxLayout>
#include <QMouseEvent>
@ -8,7 +10,7 @@
SettingsButtonWidget::SettingsButtonWidget(QWidget *parent)
: QWidget(parent), button(new QToolButton(this)), popup(new SettingsPopupWidget(nullptr))
{
button->setIcon(QPixmap("theme:icons/cogwheel"));
button->setIcon(themePixmap(QStringLiteral("icons/cogwheel")));
button->setCheckable(true);
button->setFixedSize(32, 32);
connect(button, &QToolButton::clicked, this, &SettingsButtonWidget::togglePopup);

View file

@ -142,8 +142,10 @@ void ReplayManager::processNewEvents(PlaybackMode playbackMode)
}
// backwards skip => always skip tap animation
// backwards skip => always skip damage animation (battlefield shimmer / life counter flash)
if (playbackMode == BACKWARD_SKIP) {
options |= SKIP_TAP_ANIMATION;
options |= SKIP_DAMAGE_ANIMATION;
}
emit eventReplayed(replay->event_list(currentEvent), options);

View file

@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../pixel_map_generator.h"
#include "../interface/widgets/tabs/tab_game.h"
#include "replay_manager.h"
#include "replay_quick_settings_widget.h"
@ -50,15 +51,15 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay)
replayPlayButton = new QToolButton;
replayPlayButton->setIconSize(QSize(32, 32));
QIcon playButtonIcon = QIcon();
playButtonIcon.addPixmap(QPixmap("theme:replay/start"), QIcon::Normal, QIcon::Off);
playButtonIcon.addPixmap(QPixmap("theme:replay/pause"), QIcon::Normal, QIcon::On);
playButtonIcon.addPixmap(themePixmap(QStringLiteral("replay/start")), QIcon::Normal, QIcon::Off);
playButtonIcon.addPixmap(themePixmap(QStringLiteral("replay/pause")), QIcon::Normal, QIcon::On);
replayPlayButton->setIcon(playButtonIcon);
replayPlayButton->setCheckable(true);
connect(replayPlayButton, &QToolButton::toggled, this, &ReplayWidget::replayPlayButtonToggled);
replayFastForwardButton = new QToolButton;
replayFastForwardButton->setIconSize(QSize(32, 32));
replayFastForwardButton->setIcon(QPixmap("theme:replay/fastforward"));
replayFastForwardButton->setIcon(themePixmap(QStringLiteral("replay/fastforward")));
replayFastForwardButton->setCheckable(true);
connect(replayFastForwardButton, &QToolButton::toggled, this, &ReplayWidget::updateTimeScaleFactor);

View file

@ -1,5 +1,6 @@
#include "game_selector.h"
#include "../../pixel_map_generator.h"
#include "../interface/widgets/dialogs/dlg_create_game.h"
#include "../interface/widgets/dialogs/dlg_filter_games.h"
#include "../interface/widgets/tabs/tab_account.h"
@ -95,10 +96,10 @@ GameSelector::GameSelector(AbstractClient *_client,
}
filterButton = new QPushButton;
filterButton->setIcon(QPixmap("theme:icons/search"));
filterButton->setIcon(themePixmap(QStringLiteral("icons/search")));
connect(filterButton, &QPushButton::clicked, this, &GameSelector::actSetFilter);
clearFilterButton = new QPushButton;
clearFilterButton->setIcon(QPixmap("theme:icons/clearsearch"));
clearFilterButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch")));
bool filtersSetToDefault = showFilters && gameListProxyModel->areFilterParametersSetToDefaults();
clearFilterButton->setEnabled(!filtersSetToDefault);
connect(clearFilterButton, &QPushButton::clicked, this, &GameSelector::actClearFilter);

View file

@ -1,5 +1,7 @@
#include "remote_replay_list_tree_widget.h"
#include "../../../pixel_map_generator.h"
#include <QFileIconProvider>
#include <QHeaderView>
#include <QSortFilterProxyModel>
@ -37,7 +39,7 @@ RemoteReplayList_TreeModel::RemoteReplayList_TreeModel(AbstractClient *_client,
QFileIconProvider fip;
dirIcon = fip.icon(QFileIconProvider::Folder);
fileIcon = fip.icon(QFileIconProvider::File);
lockIcon = QPixmap("theme:icons/lock");
lockIcon = themePixmap(QStringLiteral("icons/lock"));
}
RemoteReplayList_TreeModel::~RemoteReplayList_TreeModel()

View file

@ -1,6 +1,7 @@
#include "user_card_art_provider.h"
#include "../../../card_picture_loader/card_picture_loader.h"
#include "../../cards/card_art_utils.h"
#include <QPointer>
#include <libcockatrice/card/database/card_database_manager.h>
@ -52,16 +53,25 @@ void UserCardArtProvider::requestCardArt(const QString &userName, const QString
processQueue();
}
QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes)
QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes, const ExactCard &card)
{
const QSize sz = fullRes.size();
QPixmap source = fullRes;
// Sideways-layout cards (plane, siege/battle, split) store their landscape
// artwork rotated 90° inside a portrait frame. Rotate it upright first so
// the crop below lands on the horizontal art, mirroring the way
// CardInfoPictureWidget displays these cards.
const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation;
source = CardArtUtils::rotateSidewaysLayoutArt(source, card);
const QSize sz = source.size();
const int marginX = sz.width() * 0.07;
const int topMargin = sz.height() * 0.11;
const int bottomMargin = sz.height() * 0.45;
const int topMargin = landscape ? sz.height() * 0.05 : sz.height() * 0.11;
const int bottomMargin = landscape ? sz.height() * 0.42 : sz.height() * 0.45;
const QRect foilRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin);
const QRect artRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin);
return fullRes.copy(foilRect.intersected(fullRes.rect()));
return source.copy(artRect.intersected(source.rect()));
}
void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap)
@ -111,7 +121,7 @@ void UserCardArtProvider::processQueue()
// Synchronous hit (already loaded/on disk)
if (!fullRes.isNull()) {
insertIntoCache(key, cropCardArt(fullRes));
insertIntoCache(key, cropCardArt(fullRes, card));
pending.remove(key);
emit cardArtUpdated(userName);
@ -135,7 +145,7 @@ void UserCardArtProvider::processQueue()
CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
if (!fullRes.isNull()) {
self->insertIntoCache(key, self->cropCardArt(fullRes));
self->insertIntoCache(key, self->cropCardArt(fullRes, card));
}
self->pending.remove(key);

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