diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile
index f37315262..b08e568f3 100644
--- a/.ci/Arch/Dockerfile
+++ b/.ci/Arch/Dockerfile
@@ -8,6 +8,7 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \
gtest \
mariadb-libs \
ninja \
+ openssl \
protobuf \
qt6-base \
qt6-declarative \
diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile
index 0fa227d6f..e3df94ab5 100644
--- a/.ci/Debian12/Dockerfile
+++ b/.ci/Debian12/Dockerfile
@@ -15,6 +15,7 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
+ libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \
diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile
index 13e8b35c7..60e490c98 100644
--- a/.ci/Debian13/Dockerfile
+++ b/.ci/Debian13/Dockerfile
@@ -16,6 +16,7 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
+ libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \
diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora43/Dockerfile
index 68e894543..4005bbf67 100644
--- a/.ci/Fedora43/Dockerfile
+++ b/.ci/Fedora43/Dockerfile
@@ -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 \
diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile
index ffd7c1b9b..e0224cdc6 100644
--- a/.ci/Fedora44/Dockerfile
+++ b/.ci/Fedora44/Dockerfile
@@ -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 \
diff --git a/.ci/Servatrice_Debian12/Dockerfile b/.ci/Servatrice_Debian12/Dockerfile
index 21f6a036e..321aa7c0f 100644
--- a/.ci/Servatrice_Debian12/Dockerfile
+++ b/.ci/Servatrice_Debian12/Dockerfile
@@ -12,6 +12,7 @@ RUN apt-get update && \
libmariadb-dev-compat \
libprotobuf-dev \
libqt6sql6-mysql \
+ libssl-dev \
ninja-build \
protobuf-compiler \
qt6-tools-dev \
diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile
index 12320c276..10adc5e64 100644
--- a/.ci/Ubuntu24.04/Dockerfile
+++ b/.ci/Ubuntu24.04/Dockerfile
@@ -15,6 +15,7 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
+ libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \
diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile
index ce3d9cd6c..1b6cf825f 100644
--- a/.ci/Ubuntu26.04/Dockerfile
+++ b/.ci/Ubuntu26.04/Dockerfile
@@ -16,6 +16,7 @@ RUN apt-get update && \
libprotobuf-dev \
libqt6multimedia6 \
libqt6sql6-mysql \
+ libssl-dev \
ninja-build \
protobuf-compiler \
qt6-image-formats-plugins \
diff --git a/.ci/compile.sh b/.ci/compile.sh
index 8a16d3243..bd8c900c8 100755
--- a/.ci/compile.sh
+++ b/.ci/compile.sh
@@ -149,6 +149,9 @@ if [[ $MAKE_TEST ]]; then
fi
if [[ $USE_CCACHE ]]; then
flags+=("-DUSE_CCACHE=1")
+ # PCH-aware caching is required or ccache refuses to cache any TU that
+ # consumes a precompiled header, silently recompiling everything on every run.
+ ccache --set-config sloppiness=pch_defines,time_macros
if [[ $CCACHE_SIZE ]]; then
# note, this setting persists after running the script
ccache --max-size "$CCACHE_SIZE"
diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml
index 04037a74e..6ca634389 100644
--- a/.github/workflows/desktop-build.yml
+++ b/.github/workflows/desktop-build.yml
@@ -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"
diff --git a/CMakeLists.txt b/CMakeLists.txt
index bac46c2bc..0da073464 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -5,11 +5,11 @@
# This file sets all the variables shared between the projects
# like the installation path, compilation flags etc..
-# cmake 3.16 is required if using qt6
-cmake_minimum_required(VERSION 3.10)
+# 3.16 required for Qt6 and target_precompile_headers()
+cmake_minimum_required(VERSION 3.16)
# Use compiler cache (ccache)
-option(USE_CCACHE "Cache the build results with ccache" OFF)
+option(USE_CCACHE "Cache the build results with ccache" ON)
# Treat warnings as errors (Debug builds only)
option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON)
# Check for translation updates
@@ -39,13 +39,24 @@ else()
)
endif()
-if(USE_CCACHE)
+# ccache does not support MSVC and must not auto-engage on Windows
+# (it is installed unintentionally on the Windows CI runner).
+# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows
+# also opts out of ccache even though the GNUCXX branch below supports it.
+if(USE_CCACHE AND NOT WIN32)
find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM)
# Support Unix Makefiles and Ninja
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}")
+ # PCH-aware caching, matching .ci/compile.sh: without this ccache refuses
+ # to cache any TU that consumes a precompiled header, so every PCH-backed
+ # target recompiles from scratch on each build.
+ execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
message(STATUS "Found CCache ${CCACHE_PROGRAM}")
endif()
+elseif(USE_CCACHE AND WIN32)
+ # An explicit opt-in must not disappear silently on Windows.
+ message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows")
endif()
if(WIN32 OR USE_VCPKG)
@@ -184,6 +195,9 @@ elseif(CMAKE_COMPILER_IS_GNUCXX)
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}")
endif()
endforeach()
+
+ # Reduce compiler I/O by using pipes between stages instead of temp files
+ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
else()
# other: osx/llvm, bsd/llvm
set(CMAKE_CXX_FLAGS_RELEASE "-O2")
@@ -192,6 +206,9 @@ else()
else()
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra")
endif()
+
+ # Reduce compiler I/O by using pipes between stages instead of temp files
+ set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
endif()
# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning
@@ -239,11 +256,6 @@ if(WIN32)
find_package(OpenSSL REQUIRED)
if(OPENSSL_FOUND)
include_directories(${OPENSSL_INCLUDE_DIRS})
- else()
- message(
- WARNING
- "Could not find OpenSSL runtime libraries. They are not required for compiling, but needs to be available at runtime."
- )
endif()
endif()
diff --git a/Dockerfile b/Dockerfile
index 382309d47..7d3deb5fb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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/*
diff --git a/README.md b/README.md
index f22df461f..5935bb540 100644
--- a/README.md
+++ b/README.md
@@ -149,15 +149,15 @@ You can then
The following flags (with their non-default values) can be passed to `cmake`:
-| Flag | Description |
-| --- | --- |
-| `-DWITH_SERVER=1` | Build Servatrice server |
-| `-DWITH_CLIENT=0` | Don't build Cockatrice client |
-| `-DWITH_ORACLE=0` | Don't build Oracle card database tool |
-| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode
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
**Note:** `make clean` will remove the .ts files |
-| `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available |
+| Flag | Description |
+| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| `-DWITH_SERVER=1` | Build Servatrice server |
+| `-DWITH_CLIENT=0` | Don't build Cockatrice client |
+| `-DWITH_ORACLE=0` | Don't build Oracle card database tool |
+| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode
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
**Note:** `make clean` will remove the .ts files |
+| `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available |
# Run
diff --git a/cmake/pch/qtcore_pch.h b/cmake/pch/qtcore_pch.h
new file mode 100644
index 000000000..cc3dd12ee
--- /dev/null
+++ b/cmake/pch/qtcore_pch.h
@@ -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
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
diff --git a/cmake/pch/qtwidgets_pch.h b/cmake/pch/qtwidgets_pch.h
new file mode 100644
index 000000000..2c63f450e
--- /dev/null
+++ b/cmake/pch/qtwidgets_pch.h
@@ -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
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt
index a171bb32e..63ccc4e9c 100644
--- a/cockatrice/CMakeLists.txt
+++ b/cockatrice/CMakeLists.txt
@@ -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
@@ -517,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"
diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md
index 0c8bdb450..fd0a12507 100644
--- a/cockatrice/resources/help/search.md
+++ b/cockatrice/resources/help/search.md
@@ -52,6 +52,7 @@ In this list of examples below, each entry has an explanation and can be clicked
Edition:
[set:lea](#set:lea) (Cards that appear in Alpha, which has the set code LEA)
[e:lea OR e:leb](#e:lea OR e:leb) (Cards that appear in Alpha or Beta)
+[e<8ED](#e<8ED) (Cards that appear before 8th edition)
Negate:
[c:wu -c:m](#c:wu -c:m) (Any card that is white or blue, but not multicolored)
diff --git a/cockatrice/src/game/board/counter_state.cpp b/cockatrice/src/game/board/counter_state.cpp
index 6da18b662..0970e4272 100644
--- a/cockatrice/src/game/board/counter_state.cpp
+++ b/cockatrice/src/game/board/counter_state.cpp
@@ -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);
}
\ No newline at end of file
diff --git a/cockatrice/src/game/board/counter_state.h b/cockatrice/src/game/board/counter_state.h
index 0f2f16b55..4c7b34473 100644
--- a/cockatrice/src/game/board/counter_state.h
+++ b/cockatrice/src/game/board/counter_state.h
@@ -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;
diff --git a/cockatrice/src/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp
index bc68d4d7c..f146cdbb4 100644
--- a/cockatrice/src/game/game_event_handler.cpp
+++ b/cockatrice/src/game/game_event_handler.cpp
@@ -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 {
diff --git a/cockatrice/src/game/player/event_processing_options.h b/cockatrice/src/game/player/event_processing_options.h
index 4c7663789..06238d77e 100644
--- a/cockatrice/src/game/player/event_processing_options.h
+++ b/cockatrice/src/game/player/event_processing_options.h
@@ -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
diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp
index bc48298f7..277b8b1d4 100644
--- a/cockatrice/src/game/player/player_event_handler.cpp
+++ b/cockatrice/src/game/player/player_event_handler.cpp
@@ -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));
diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h
index 48ad85e88..300cacd08 100644
--- a/cockatrice/src/game/player/player_event_handler.h
+++ b/cockatrice/src/game/player/player_event_handler.h
@@ -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);
diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp
index 45ba09aac..143df5c57 100644
--- a/cockatrice/src/game/player/player_logic.cpp
+++ b/cockatrice/src/game/player/player_logic.cpp
@@ -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()) {
diff --git a/cockatrice/src/game/player/player_manager.cpp b/cockatrice/src/game/player/player_manager.cpp
index 6772d3ff1..8486efbeb 100644
--- a/cockatrice/src/game/player/player_manager.cpp
+++ b/cockatrice/src/game/player/player_manager.cpp
@@ -75,6 +75,14 @@ PlayerLogic *PlayerManager::getPlayer(int playerId) const
return player;
}
+void PlayerManager::clearSpectators()
+{
+ const QList spectatorIds = spectators.keys();
+ for (int spectatorId : spectatorIds) {
+ removeSpectator(spectatorId);
+ }
+}
+
void PlayerManager::onPlayerConceded(int playerId, bool conceded)
{
// Everything else cares about this
diff --git a/cockatrice/src/game/player/player_manager.h b/cockatrice/src/game/player/player_manager.h
index 2f8b87af8..504e65396 100644
--- a/cockatrice/src/game/player/player_manager.h
+++ b/cockatrice/src/game/player/player_manager.h
@@ -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;
diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp
index e63117e13..4ba04804f 100644
--- a/cockatrice/src/game_graphics/board/abstract_counter.cpp
+++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp
@@ -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).
diff --git a/cockatrice/src/game_graphics/board/abstract_counter.h b/cockatrice/src/game_graphics/board/abstract_counter.h
index 9ddcc6d58..67b5b4074 100644
--- a/cockatrice/src/game_graphics/board/abstract_counter.h
+++ b/cockatrice/src/game_graphics/board/abstract_counter.h
@@ -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;
diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp
index c40c8c214..c2dc455cc 100644
--- a/cockatrice/src/game_graphics/board/card_item.cpp
+++ b/cockatrice/src/game_graphics/board/card_item.cpp
@@ -316,7 +316,7 @@ void CardItem::drawAttachArrow()
for (const auto &item : scene()->selectedItems()) {
CardItem *card = qgraphicsitem_cast(item);
- if (card == nullptr) {
+ if (card == nullptr || card == this) {
continue;
}
if (card->getZone() != state->getZone()) {
diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp
index 1278737a0..1acd02a75 100644
--- a/cockatrice/src/game_graphics/deckview/deck_view.cpp
+++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp
@@ -10,7 +10,6 @@
#include
#include
#include
-#include
#include
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(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);
diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp
index 87af4c73c..17af7618b 100644
--- a/cockatrice/src/game_graphics/game_scene.cpp
+++ b/cockatrice/src/game_graphics/game_scene.cpp
@@ -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 zoneViewCopy = zoneViews;
+ for (ZoneViewWidget *zone : zoneViewCopy) {
if (zone->getPlayer() == player) {
zone->close();
}
@@ -659,7 +669,10 @@ CardItem *GameScene::findTopmostCardInZone(const QList &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 zoneViewCopy = zoneViews;
+ for (auto *view : zoneViewCopy) {
ZoneViewZone *temp = view->getZone();
if (temp->getLogic()->getName() == zoneName && temp->getLogic()->getPlayer() == player &&
qobject_cast(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();
}
diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h
index c12696189..859d7a6eb 100644
--- a/cockatrice/src/game_graphics/game_scene.h
+++ b/cockatrice/src/game_graphics/game_scene.h
@@ -54,9 +54,11 @@ private:
QPointer hoveredCard; ///< Currently hovered card
QBasicTimer *animationTimer; ///< Timer for scene animations
QHash 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
+ 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.
diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp
index 7eb3945b3..08cb6cac9 100644
--- a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp
+++ b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp
@@ -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"));
}
diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.h b/cockatrice/src/game_graphics/player/menu/tally_menu.h
index acd1daf67..11802fd20 100644
--- a/cockatrice/src/game_graphics/player/menu/tally_menu.h
+++ b/cockatrice/src/game_graphics/player/menu/tally_menu.h
@@ -24,6 +24,7 @@ private:
QAction *aTallyNone = nullptr;
QAction *aTallySubtypes = nullptr;
QAction *aTallyTotalPower = nullptr;
+ QAction *aTallyTotalToughness = nullptr;
QAction *createTallyAction(TallyType tallyType);
};
diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp
index 8bf2703e1..122ab83be 100644
--- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp
+++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp
@@ -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();
}
diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp
index 4268e1019..13a077af8 100644
--- a/cockatrice/src/game_graphics/player/player_list_widget.cpp
+++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp
@@ -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 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 i(players);
diff --git a/cockatrice/src/game_graphics/player/player_list_widget.h b/cockatrice/src/game_graphics/player/player_list_widget.h
index a53cfa989..f2f0be5fd 100644
--- a/cockatrice/src/game_graphics/player/player_list_widget.h
+++ b/cockatrice/src/game_graphics/player/player_list_widget.h
@@ -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
diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp
index 910ee9c17..d6c28370d 100644
--- a/cockatrice/src/game_graphics/player/player_target.cpp
+++ b/cockatrice/src/game_graphics/player/player_target.cpp
@@ -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());
diff --git a/cockatrice/src/game_graphics/player/player_target.h b/cockatrice/src/game_graphics/player/player_target.h
index af0e9c8b7..1d06c6274 100644
--- a/cockatrice/src/game_graphics/player/player_target.h
+++ b/cockatrice/src/game_graphics/player/player_target.h
@@ -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;
diff --git a/cockatrice/src/game_graphics/tally/stats_tally.cpp b/cockatrice/src/game_graphics/tally/stats_tally.cpp
index e7a6621fa..7e05c3fb1 100644
--- a/cockatrice/src/game_graphics/tally/stats_tally.cpp
+++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp
@@ -34,3 +34,31 @@ QList StatsTally::computeTotalPower(const QList &cards)
QString name = QCoreApplication::translate("StatsTally", "Total Power");
return {TallyRow{name, QString::number(total)}};
}
+
+static int sumToughness(const QList &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 StatsTally::computeTotalToughness(const QList &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)}};
+}
diff --git a/cockatrice/src/game_graphics/tally/stats_tally.h b/cockatrice/src/game_graphics/tally/stats_tally.h
index 4c3d93b56..e499587eb 100644
--- a/cockatrice/src/game_graphics/tally/stats_tally.h
+++ b/cockatrice/src/game_graphics/tally/stats_tally.h
@@ -16,6 +16,14 @@ namespace StatsTally
*/
QList computeTotalPower(const QList &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 computeTotalToughness(const QList &cards);
+
} // namespace StatsTally
#endif // COCKATRICE_STATS_TALLY_H
diff --git a/cockatrice/src/game_graphics/tally/tally.cpp b/cockatrice/src/game_graphics/tally/tally.cpp
index aa2cae024..21806ee84 100644
--- a/cockatrice/src/game_graphics/tally/tally.cpp
+++ b/cockatrice/src/game_graphics/tally/tally.cpp
@@ -21,6 +21,8 @@ QList Tally::compute(const QList &cards, const TallyType t
return SubtypeTally::countSubtypes(cards);
case TallyType::TotalPower:
return StatsTally::computeTotalPower(cards);
+ case TallyType::TotalToughness:
+ return StatsTally::computeTotalToughness(cards);
}
return {};
}
diff --git a/cockatrice/src/game_graphics/tally/tally.h b/cockatrice/src/game_graphics/tally/tally.h
index 97406cddb..84c54918f 100644
--- a/cockatrice/src/game_graphics/tally/tally.h
+++ b/cockatrice/src/game_graphics/tally/tally.h
@@ -21,7 +21,8 @@ enum class TallyType
None,
Subtypes,
TotalPower,
- MaxValue = TotalPower // sentinel value
+ TotalToughness,
+ MaxValue = TotalToughness // sentinel value
};
namespace Tally
diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp
index b52a4955a..1a8f7a910 100644
--- a/cockatrice/src/game_graphics/zones/hand_zone.cpp
+++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp
@@ -41,7 +41,8 @@ void HandZone::handleDropEvent(const QList &dragItems,
}
}
} else {
- x = calcDropIndexFromY(dropPoint.y());
+ bool sameZone = startZone == getLogic();
+ x = calcDropIndexFromY(dropPoint.y(), !sameZone);
}
Command_MoveCard cmd;
diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp
index c58c41b92..470c70fcf 100644
--- a/cockatrice/src/game_graphics/zones/select_zone.cpp
+++ b/cockatrice/src/game_graphics/zones/select_zone.cpp
@@ -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()
diff --git a/cockatrice/src/game_graphics/zones/select_zone.h b/cockatrice/src/game_graphics/zones/select_zone.h
index 7408f29b6..b5d3ca37a 100644
--- a/cockatrice/src/game_graphics/zones/select_zone.h
+++ b/cockatrice/src/game_graphics/zones/select_zone.h
@@ -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.
diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp
index e9b14f13d..ff62097c7 100644
--- a/cockatrice/src/game_graphics/zones/stack_zone.cpp
+++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp
@@ -57,18 +57,14 @@ void StackZone::handleDropEvent(const QList &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(cards.size());
}
Command_MoveCard cmd;
diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp
index 2f46e7941..7daafb610 100644
--- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp
+++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp
@@ -138,7 +138,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()) {
diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp
index 39a0c1071..f03339da8 100644
--- a/cockatrice/src/interface/deck_loader/deck_loader.cpp
+++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp
@@ -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 cardsByType;
QMap cardTotalByType;
int cardTotal = 0;
+ QList subZones;
for (int j = 0; j < zoneNode->size(); j++) {
auto *card = dynamic_cast(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(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 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 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 cards;
+ QVector subZones;
+ for (int i = 0; i < node->size(); i++) {
+ if (auto *card = dynamic_cast(node->at(i))) {
+ cards.append(card);
+ } else if (auto *zone = dynamic_cast(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(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(node->at(i)));
- }
+ QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition();
+ printDeckListNode(&cellCursor, subZone);
}
cursor->movePosition(QTextCursor::End);
diff --git a/cockatrice/src/interface/deck_loader/deck_loader.h b/cockatrice/src/interface/deck_loader/deck_loader.h
index ac23e1ee0..b851c6895 100644
--- a/cockatrice/src/interface/deck_loader/deck_loader.h
+++ b/cockatrice/src/interface/deck_loader/deck_loader.h
@@ -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 cards,
bool addComments = true,
- bool addSetNameAndNumber = true);
+ bool addSetNameAndNumber = true,
+ const QString &boardZoneName = QString());
};
#endif
diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp
index d7d67c6bf..674f3dd4c 100644
--- a/cockatrice/src/interface/pixel_map_generator.cpp
+++ b/cockatrice/src/interface/pixel_map_generator.cpp
@@ -14,7 +14,7 @@
#define DEFAULT_COLOR_MODERATOR_LEFT "#ffffff";
#define DEFAULT_COLOR_MODERATOR_RIGHT "#000000";
#define DEFAULT_COLOR_ADMIN "#ff2701";
-#define DEFAULT_COLOR_DEVELOPER "#800020"
+#define DEFAULT_COLOR_DEVELOPER "#B8B8B8"
/**
* Clamps an svg render size so that rendering does not exceed a multiple of the requested size.
diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.cpp b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp
new file mode 100644
index 000000000..b26b73593
--- /dev/null
+++ b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp
@@ -0,0 +1,18 @@
+#include "card_art_utils.h"
+
+#include
+#include
+
+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
\ No newline at end of file
diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.h b/cockatrice/src/interface/widgets/cards/card_art_utils.h
new file mode 100644
index 000000000..5c331a12c
--- /dev/null
+++ b/cockatrice/src/interface/widgets/cards/card_art_utils.h
@@ -0,0 +1,25 @@
+#ifndef CARD_ART_UTILS_H
+#define CARD_ART_UTILS_H
+
+#include
+
+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
\ No newline at end of file
diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp
index 3f36e559c..bfbdd7e42 100644
--- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp
+++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp
@@ -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));
}
}
diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h
index 2308ccf8d..a3bf70981 100644
--- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h
+++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h
@@ -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;
diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp
index 79ae087d7..de622bdc8 100644
--- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp
+++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp
@@ -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
#include
@@ -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
diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp
index eaf3a67b0..b00d9db1e 100644
--- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp
+++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp
@@ -5,6 +5,7 @@
#include "libcockatrice/card/database/card_database_manager.h"
#include
+#include
#include
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 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);
}
}
diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h
index b426fca30..53f3fa7cf 100644
--- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h
+++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h
@@ -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);
diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp
index 7c782b074..00388a3cd 100644
--- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp
+++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp
@@ -90,6 +90,13 @@ void CardDatabaseView::decrementCard(const QString &zoneName)
emit cardDecremented(currentCardName(), zoneName);
}
+void CardDatabaseView::setZoneMenuProvider(const std::function>()> &provider,
+ const std::function &newZoneHandler)
+{
+ zoneMenuProvider = provider;
+ this->newZoneHandler = newZoneHandler;
+}
+
void CardDatabaseView::updateCard(const QModelIndex ¤t, 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); });
diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h
index 175ec12b9..668444199 100644
--- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h
+++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h
@@ -4,6 +4,7 @@
#include "../../key_signals.h"
#include
+#include
#include
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>()> 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 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>()> &provider,
+ const std::function &newZoneHandler);
+
signals:
void cardChanged(const QString &cardName);
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp
index 2a491de4f..6269f0323 100644
--- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp
@@ -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
+
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> {
+ QList> 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);
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp
index 14defc8e9..e2175a358 100644
--- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp
@@ -7,15 +7,18 @@
#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
#include "deck_list_style_proxy.h"
#include "deck_state_manager.h"
+#include "deck_zone_dialog.h"
#include
#include
#include
#include
+#include
#include
#include
#include
#include
+#include
#include
#include
#include
@@ -772,14 +775,213 @@ void DeckEditorDeckDockWidget::offsetCountAtIndex(const QModelIndex &idx, bool i
void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point)
{
+ const QModelIndex sourceIndex = proxy->mapToSource(deckView->indexAt(point));
+
QMenu menu;
+ const bool isCustomZoneRow = sourceIndex.isValid() && sourceIndex.data(DeckRoles::IsCustomZoneRole).toBool();
+ const bool isBoardZoneRow = sourceIndex.isValid() && !isCustomZoneRow && !sourceIndex.parent().isValid();
+ const bool isCardRow =
+ sourceIndex.isValid() && !isCustomZoneRow && !isBoardZoneRow && !getModel()->hasChildren(sourceIndex);
+
+ // Walk the row up to its top-level node to find the hosting board. Cards in
+ // the tokens board cannot be moved (moveCardToZone bails for it), so the
+ // move menu is skipped for them.
+ QString currentBoardName;
+ QModelIndex board = sourceIndex.parent();
+ while (board.isValid() && board.parent().isValid()) {
+ board = board.parent();
+ }
+ if (board.isValid()) {
+ currentBoardName = board.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
+ }
+
+ if (isCardRow) {
+ if (currentBoardName != DECK_ZONE_TOKENS) {
+ addMoveToZoneMenu(&menu, sourceIndex, currentBoardName);
+ menu.addSeparator();
+ }
+ } else if (isCustomZoneRow) {
+ const QString zoneName =
+ sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
+
+ QAction *renameAction = menu.addAction(tr("&Rename zone..."));
+ connect(renameAction, &QAction::triggered, this, [this, zoneName] {
+ // The unchanged name must not validate as a duplicate.
+ const QString newName =
+ DeckZoneDialog::promptForRename(this, zoneName, [this, zoneName](const QString &candidate) {
+ return candidate == zoneName ? QString() : deckStateManager->validateNewZoneName(candidate);
+ });
+ if (!newName.isEmpty() && newName != zoneName) {
+ deckStateManager->renameCustomZone(zoneName, newName);
+ }
+ });
+
+ QMenu *boardMenu = menu.addMenu(tr("Change &board"));
+ addChangeBoardMenu(boardMenu, zoneName);
+
+ QAction *deleteAction = menu.addAction(tr("&Delete zone"));
+ const bool zoneHasCards = getModel()->hasChildren(sourceIndex);
+ deleteAction->setEnabled(!zoneHasCards);
+ if (zoneHasCards) {
+ deleteAction->setToolTip(tr("Move or remove all cards first."));
+ menu.setToolTipsVisible(true);
+ }
+ connect(deleteAction, &QAction::triggered, this, [this, zoneName] {
+ const auto result =
+ QMessageBox::warning(this, tr("Delete zone"), tr("Delete the zone \"%1\"?").arg(zoneName),
+ QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
+ if (result == QMessageBox::Yes) {
+ deckStateManager->removeCustomZone(zoneName);
+ }
+ });
+ menu.addSeparator();
+ } else if (isBoardZoneRow) {
+ const QString boardName =
+ sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString();
+ // Tokens cannot host custom zones, so only offer the action on real boards.
+ const bool canHostCustomZones =
+ boardName == DECK_ZONE_MAIN || boardName == DECK_ZONE_SIDE || boardName == DECK_ZONE_MAYBEBOARD;
+ if (canHostCustomZones) {
+ addNewZoneAction(&menu, boardName);
+ menu.addSeparator();
+ }
+ } else if (!sourceIndex.isValid()) {
+ addNewZoneAction(&menu);
+ menu.addSeparator();
+ }
+
QAction *selectPrinting = menu.addAction(tr("Select Printing"));
connect(selectPrinting, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::showPrintingSelector);
menu.exec(deckView->mapToGlobal(point));
}
+void DeckEditorDeckDockWidget::addMoveToZoneMenu(QMenu *menu,
+ const QModelIndex &sourceCardIndex,
+ const QString ¤tBoardName)
+{
+ // 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();
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h
index 9db01e2e5..1e5f4e677 100644
--- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h
@@ -19,6 +19,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -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 ¤tBoardName);
+ 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 ¤t);
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp
index eda741728..e563729a4 100644
--- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp
@@ -2,6 +2,7 @@
#include
#include
+#include
DeckStateManager::DeckStateManager(QObject *parent)
: QObject(parent), deckList(QSharedPointer(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 &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
*/
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h
index b9c99903e..2c8b34a39 100644
--- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h
@@ -5,6 +5,7 @@
#include "deck_list_model.h"
#include
+#include
#include
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 &operation);
void doCardModified();
void doMetadataModified();
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp
new file mode 100644
index 000000000..9a0be2570
--- /dev/null
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp
@@ -0,0 +1,145 @@
+#include "deck_zone_dialog.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+DeckZoneDialog::DeckZoneDialog(QWidget *parent,
+ const QString &initialBoardName,
+ const std::function &_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 &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 ¤tZoneName,
+ const std::function &nameValidator)
+{
+ DeckZoneDialog dialog(parent, {}, nameValidator, false);
+ dialog.setZoneName(currentZoneName);
+ return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString();
+}
diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h
new file mode 100644
index 000000000..6f55617a8
--- /dev/null
+++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h
@@ -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
+#include
+#include
+#include
+
+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 &_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 &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 ¤tZoneName,
+ const std::function &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 nameValidator;
+ bool allowBoardSelection;
+};
+
+#endif // DECK_ZONE_DIALOG_H
diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp
index 883cfcd03..4698b011f 100644
--- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp
+++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp
@@ -132,7 +132,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);
diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp
index 618ac6f26..4af02fe4f 100644
--- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp
+++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp
@@ -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();
diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h
index 2c186ef95..21d7b6e06 100644
--- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h
+++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h
@@ -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;
diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp
index 12116de7a..50e8ff63d 100644
--- a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp
+++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp
@@ -15,6 +15,7 @@
#include
#include
#include
+#include
#include
#include
@@ -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(qMin(total, INT_MAX)) : 0);
+ progressBar->setValue(static_cast(qMin(done, INT_MAX)));
+ if (total > 0) {
+ const int percent = static_cast((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();
diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h
index 0461d11d5..870e759ea 100644
--- a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h
+++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h
@@ -30,6 +30,7 @@ public:
void retranslateUi() override;
void onUpdateFinished(bool success);
+ void onUpdateProgress(const QString &stage, qint64 done, qint64 total);
signals:
void updateRequested();
diff --git a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp
index 57706cf93..9459c5ea9 100644
--- a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp
+++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp
@@ -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));
diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.cpp b/cockatrice/src/interface/widgets/replay/replay_manager.cpp
index a2c1e0ff0..c51b96b6c 100644
--- a/cockatrice/src/interface/widgets/replay/replay_manager.cpp
+++ b/cockatrice/src/interface/widgets/replay/replay_manager.cpp
@@ -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);
diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp
index 3a1876fa1..2ba745715 100644
--- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp
@@ -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
#include
@@ -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);
diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h
index 2592237c4..e8283a891 100644
--- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h
+++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h
@@ -6,6 +6,7 @@
#include
#include
#include
+#include
class UserCardArtProvider : public QObject
{
@@ -16,7 +17,7 @@ public:
void requestCardArt(const QString &userName, const QString &cardName, const QString &providerId);
const QMap &cache() const;
- static QPixmap cropCardArt(const QPixmap &fullRes);
+ static QPixmap cropCardArt(const QPixmap &fullRes, const ExactCard &card);
signals:
void cardArtUpdated(const QString &userName);
diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp
index 532112964..d49e3d540 100644
--- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp
@@ -560,7 +560,7 @@ void UserCardArtSettingsDialog::reloadPreview()
return;
}
- currentPixmap = UserCardArtProvider::cropCardArt(fullRes);
+ currentPixmap = UserCardArtProvider::cropCardArt(fullRes, card);
preview->setPixmap(currentPixmap);
preview->setParams(currentParams);
diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp
index 72e7c41b2..0d2267a63 100644
--- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp
+++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp
@@ -452,7 +452,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
}
}
aDetails->setEnabled(true);
- aChat->setEnabled(anotherUser && online);
+ aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName));
aShowGames->setEnabled(online);
aReport->setEnabled(anotherUser);
aAddToBuddyList->setEnabled(anotherUser);
@@ -625,7 +625,15 @@ void UserContextMenu::execAddToIgnore(const QString &userName)
Command_AddToList cmd;
cmd.set_list("ignore");
cmd.set_user_name(userName.toStdString());
- client->sendCommand(client->prepareSessionCommand(cmd));
+ PendingCommand *pend = client->prepareSessionCommand(cmd);
+ connect(pend, &PendingCommand::finished, this,
+ [this, userName](const Response &response, const CommandContainer &, const QVariant &) {
+ if (response.response_code() == Response::RespOk) {
+ QMessageBox::information(static_cast(parent()), tr("Ignore list"),
+ tr("%1 has been added to your ignore list.").arg(userName));
+ }
+ });
+ client->sendCommand(pend);
}
void UserContextMenu::execRemoveFromIgnore(const QString &userName)
diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp
index 881c54167..c8494f095 100644
--- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp
+++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp
@@ -154,6 +154,48 @@ AppearanceSettingsPage::AppearanceSettingsPage()
homeTabGroupBox = new QGroupBox;
homeTabGroupBox->setLayout(homeTabGrid);
+ // Playmat settings
+ playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll);
+ playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly);
+ playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone);
+ int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility());
+ if (visIdx >= 0) {
+ playmatVisibilityCombo.setCurrentIndex(visIdx);
+ }
+ connect(&playmatVisibilityCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) {
+ SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt());
+ });
+ playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo);
+
+ // Playmat mode: Override / Fallback / Deck-only
+ playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck);
+ playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback);
+ playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly);
+ int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode());
+ if (modeIdx >= 0) {
+ playmatModeCombo.setCurrentIndex(modeIdx);
+ }
+ connect(&playmatModeCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) {
+ SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt());
+ });
+ playmatModeLabel.setBuddy(&playmatModeCombo);
+
+ // User-level playmat settings: fallback collection.
+ connect(&playmatDefaultEditButton, &QPushButton::clicked, this,
+ &AppearanceSettingsPage::openPlaymatCollectionDialog);
+
+ auto *playmatGrid = new QGridLayout;
+ playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1);
+ playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1);
+ playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1);
+ playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1);
+ playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1);
+ playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1);
+
+ playmatGroupBox = new QGroupBox;
+ playmatGroupBox->setLayout(playmatGrid);
+
+ // Styling settings
styleUserListCheckBox.setChecked(settings.appearance().getStyleUserList());
connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(),
&AppearanceSettings::setStyleUserList);
@@ -259,7 +301,6 @@ AppearanceSettingsPage::AppearanceSettingsPage()
cardLayoutGroupBox->setLayout(cardLayoutGrid);
// Card counter colors
-
auto *cardCounterColorsLayout = new QGridLayout;
cardCounterColorsLayout->setColumnStretch(1, 1);
cardCounterColorsLayout->setColumnStretch(3, 1);
@@ -339,47 +380,6 @@ AppearanceSettingsPage::AppearanceSettingsPage()
tableGroupBox = new QGroupBox;
tableGroupBox->setLayout(tableGrid);
- // Playmat settings
- playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll);
- playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly);
- playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone);
- int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility());
- if (visIdx >= 0) {
- playmatVisibilityCombo.setCurrentIndex(visIdx);
- }
- connect(&playmatVisibilityCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) {
- SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt());
- });
- playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo);
-
- // Playmat mode: Override / Fallback / Deck-only
- playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck);
- playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback);
- playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly);
- int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode());
- if (modeIdx >= 0) {
- playmatModeCombo.setCurrentIndex(modeIdx);
- }
- connect(&playmatModeCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) {
- SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt());
- });
- playmatModeLabel.setBuddy(&playmatModeCombo);
-
- // User-level playmat settings: fallback collection.
- connect(&playmatDefaultEditButton, &QPushButton::clicked, this,
- &AppearanceSettingsPage::openPlaymatCollectionDialog);
-
- auto *playmatGrid = new QGridLayout;
- playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1);
- playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1);
- playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1);
- playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1);
- playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1);
- playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1);
-
- playmatGroupBox = new QGroupBox;
- playmatGroupBox->setLayout(playmatGrid);
-
// putting it all together
auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(themeGroupBox);
@@ -512,6 +512,12 @@ void AppearanceSettingsPage::retranslateUi()
homeTabButtonColorSourceBox.setToolTip(
tr("Automatic: extract from background if present, otherwise use theme default"));
+ playmatGroupBox->setTitle(tr("Playmat settings"));
+ playmatVisibilityLabel.setText(tr("Playmat visibility:"));
+ playmatModeLabel.setText(tr("Default collection behavior:"));
+ playmatDefaultLabel.setText(tr("Default playmat collection:"));
+ playmatDefaultEditButton.setText(tr("Edit..."));
+
stylingGroupBox->setTitle(tr("Styling settings"));
styleUserListCheckBox.setText(tr("Style user list"));
@@ -554,9 +560,4 @@ void AppearanceSettingsPage::retranslateUi()
tableGroupBox->setTitle(tr("Table grid layout"));
invertVerticalCoordinateCheckBox.setText(tr("Invert vertical coordinate"));
minPlayersForMultiColumnLayoutLabel.setText(tr("Minimum player count for multi-column layout:"));
- playmatGroupBox->setTitle(tr("Playmat settings"));
- playmatVisibilityLabel.setText(tr("Playmat visibility:"));
- playmatModeLabel.setText(tr("Default collection behavior:"));
- playmatDefaultLabel.setText(tr("Default playmat collection:"));
- playmatDefaultEditButton.setText(tr("Edit..."));
}
diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h
index 6b0369694..8db71ff8f 100644
--- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h
+++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h
@@ -44,46 +44,55 @@ private:
QLabel homeTabButtonColorSourceLabel;
QComboBox homeTabButtonColorSourceBox;
- QCheckBox styleUserListCheckBox;
- QCheckBox showShortcutsCheckBox;
- QCheckBox showGameSelectorFilterToolbarCheckBox;
- QLabel minPlayersForMultiColumnLayoutLabel;
- QLabel maxFontSizeForCardsLabel;
- QCheckBox overrideAllCardArtWithPersonalPreferenceCheckBox;
- QCheckBox bumpSetsWithCardsInDeckToTopCheckBox;
- QCheckBox displayCardNamesCheckBox;
- QCheckBox autoRotateSidewaysLayoutCardsCheckBox;
- QCheckBox cardScalingCheckBox;
- QCheckBox roundCardCornersCheckBox;
- QLabel verticalCardOverlapPercentLabel;
- QSpinBox verticalCardOverlapPercentBox;
- QLabel cardViewInitialRowsMaxLabel;
- QSpinBox cardViewInitialRowsMaxBox;
- QLabel cardViewExpandedRowsMaxLabel;
- QSpinBox cardViewExpandedRowsMaxBox;
- QCheckBox horizontalHandCheckBox;
- QCheckBox leftJustifiedHandCheckBox;
- QCheckBox invertVerticalCoordinateCheckBox;
QLabel playmatVisibilityLabel;
QComboBox playmatVisibilityCombo;
QLabel playmatModeLabel;
QComboBox playmatModeCombo;
QLabel playmatDefaultLabel;
QPushButton playmatDefaultEditButton;
+
+ QCheckBox styleUserListCheckBox;
+
+ QCheckBox showShortcutsCheckBox;
+ QCheckBox showGameSelectorFilterToolbarCheckBox;
+
+ QCheckBox overrideAllCardArtWithPersonalPreferenceCheckBox;
+ QCheckBox bumpSetsWithCardsInDeckToTopCheckBox;
+
+ QCheckBox displayCardNamesCheckBox;
+ QCheckBox autoRotateSidewaysLayoutCardsCheckBox;
+ QCheckBox cardScalingCheckBox;
+ QCheckBox roundCardCornersCheckBox;
+ QLabel maxFontSizeForCardsLabel;
+ QSpinBox maxFontSizeForCardsEdit;
+
+ QLabel verticalCardOverlapPercentLabel;
+ QSpinBox verticalCardOverlapPercentBox;
+ QLabel cardViewInitialRowsMaxLabel;
+ QSpinBox cardViewInitialRowsMaxBox;
+ QLabel cardViewExpandedRowsMaxLabel;
+ QSpinBox cardViewExpandedRowsMaxBox;
+
+ QList cardCounterNames;
+
+ QCheckBox horizontalHandCheckBox;
+ QCheckBox leftJustifiedHandCheckBox;
+
+ QCheckBox invertVerticalCoordinateCheckBox;
+ QLabel minPlayersForMultiColumnLayoutLabel;
+ QSpinBox minPlayersForMultiColumnLayoutEdit;
+
QGroupBox *themeGroupBox;
QGroupBox *homeTabGroupBox;
+ QGroupBox *playmatGroupBox;
QGroupBox *stylingGroupBox;
QGroupBox *menuGroupBox;
QGroupBox *printingsGroupBox;
QGroupBox *cardsGroupBox;
QGroupBox *cardLayoutGroupBox;
- QGroupBox *handGroupBox;
- QGroupBox *playmatGroupBox;
- QGroupBox *tableGroupBox;
QGroupBox *cardCountersGroupBox;
- QList cardCounterNames;
- QSpinBox minPlayersForMultiColumnLayoutEdit;
- QSpinBox maxFontSizeForCardsEdit;
+ QGroupBox *handGroupBox;
+ QGroupBox *tableGroupBox;
public:
AppearanceSettingsPage();
diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp
index a293660f9..62b06fb60 100644
--- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp
+++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp
@@ -425,29 +425,28 @@ void GeneralSettingsPage::updateStartupServerControlsVisibility()
void GeneralSettingsPage::retranslateUi()
{
+ const auto &settings = SettingsCache::instance();
+
languageGroupBox->setTitle(tr("Language settings"));
languageLabel.setText(tr("Language:"));
-
- versionGroupBox->setTitle(tr("Version settings"));
- cardDatabaseGroupBox->setTitle(tr("Card database"));
- startupGroupBox->setTitle(tr("Startup settings"));
-
- if (SettingsCache::instance().getIsPortableBuild()) {
- pathsGroupBox->setTitle(tr("Paths (editing disabled in portable mode)"));
- } else {
- pathsGroupBox->setTitle(tr("Paths"));
- }
advertiseTranslationPageLabel.setText(
QString("%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations")));
- deckPathLabel.setText(tr("Decks directory:"));
- filtersPathLabel.setText(tr("Filters directory:"));
- replaysPathLabel.setText(tr("Replays directory:"));
- picsPathLabel.setText(tr("Pictures directory:"));
- cardDatabasePathLabel.setText(tr("Card database:"));
- customCardDatabasePathLabel.setText(tr("Custom database directory:"));
- tokenDatabasePathLabel.setText(tr("Token database:"));
+
+ versionGroupBox->setTitle(tr("Version settings"));
updateReleaseChannelLabel.setText(tr("Update channel"));
startupUpdateCheckCheckBox.setText(tr("Check for client updates on startup"));
+ updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client"));
+ newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice"));
+
+ // We can't change the strings after they're put into the QComboBox, so this is our workaround
+ int oldIndex = updateReleaseChannelBox.currentIndex();
+ updateReleaseChannelBox.clear();
+ for (ReleaseChannel *chan : settings.getUpdateReleaseChannels()) {
+ updateReleaseChannelBox.addItem(tr(chan->getName().toUtf8()));
+ }
+ updateReleaseChannelBox.setCurrentIndex(oldIndex);
+
+ cardDatabaseGroupBox->setTitle(tr("Card database"));
startupCardUpdateCheckBehaviorLabel.setText(tr("Check for card database updates on startup"));
startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexNone, tr("Don't check"));
startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexPrompt,
@@ -456,8 +455,13 @@ void GeneralSettingsPage::retranslateUi()
tr("Always update in the background"));
cardUpdateCheckIntervalLabel.setText(tr("Check for card database updates every"));
cardUpdateCheckIntervalSpinBox.setSuffix(tr(" days"));
- updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client"));
- newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice"));
+
+ QDate lastCheckDate = settings.updates().getLastCardUpdateCheck();
+ int daysAgo = lastCheckDate.daysTo(QDate::currentDate());
+ lastCardUpdateCheckDateLabel.setText(
+ tr("Last update check on %1 (%2 days ago)").arg(lastCheckDate.toString()).arg(daysAgo));
+
+ startupGroupBox->setTitle(tr("Startup settings"));
showTipsOnStartup.setText(tr("Show tips on startup"));
startupTabLabel.setText(tr("Startup tab:"));
startupTabSelector.setItemText(StartupTab::StartupTabHome, tr("Home"));
@@ -473,21 +477,18 @@ void GeneralSettingsPage::retranslateUi()
startupServerLabel.setText(tr("Server:"));
startupRoomLabel.setText(tr("Room:"));
startupRoomNameEdit->setPlaceholderText(tr("Room name"));
- resetAllPathsButton->setText(tr("Reset all paths"));
- const auto &settings = SettingsCache::instance();
-
- QDate lastCheckDate = settings.updates().getLastCardUpdateCheck();
- int daysAgo = lastCheckDate.daysTo(QDate::currentDate());
-
- lastCardUpdateCheckDateLabel.setText(
- tr("Last update check on %1 (%2 days ago)").arg(lastCheckDate.toString()).arg(daysAgo));
-
- // We can't change the strings after they're put into the QComboBox, so this is our workaround
- int oldIndex = updateReleaseChannelBox.currentIndex();
- updateReleaseChannelBox.clear();
- for (ReleaseChannel *chan : settings.getUpdateReleaseChannels()) {
- updateReleaseChannelBox.addItem(tr(chan->getName().toUtf8()));
+ if (settings.getIsPortableBuild()) {
+ pathsGroupBox->setTitle(tr("Paths (editing disabled in portable mode)"));
+ } else {
+ pathsGroupBox->setTitle(tr("Paths"));
}
- updateReleaseChannelBox.setCurrentIndex(oldIndex);
-}
\ No newline at end of file
+ deckPathLabel.setText(tr("Decks directory:"));
+ filtersPathLabel.setText(tr("Filters directory:"));
+ replaysPathLabel.setText(tr("Replays directory:"));
+ picsPathLabel.setText(tr("Pictures directory:"));
+ cardDatabasePathLabel.setText(tr("Card database:"));
+ customCardDatabasePathLabel.setText(tr("Custom database directory:"));
+ tokenDatabasePathLabel.setText(tr("Token database:"));
+ resetAllPathsButton->setText(tr("Reset all paths"));
+}
diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h
index 8dd7e8798..e0c1a47bf 100644
--- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h
+++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h
@@ -42,6 +42,37 @@ private:
QGroupBox *startupGroupBox;
QGroupBox *pathsGroupBox;
+ QLabel languageLabel;
+ QComboBox languageBox;
+ QLabel advertiseTranslationPageLabel;
+
+ QLabel updateReleaseChannelLabel;
+ QComboBox updateReleaseChannelBox;
+ QCheckBox startupUpdateCheckCheckBox;
+ QCheckBox updateNotificationCheckBox;
+ QCheckBox newVersionOracleCheckBox;
+
+ QLabel startupCardUpdateCheckBehaviorLabel;
+ QComboBox startupCardUpdateCheckBehaviorSelector;
+ QLabel cardUpdateCheckIntervalLabel;
+ QSpinBox cardUpdateCheckIntervalSpinBox;
+ QLabel lastCardUpdateCheckDateLabel;
+
+ QCheckBox showTipsOnStartup;
+ QLabel startupTabLabel;
+ QComboBox startupTabSelector;
+ QLabel startupServerLabel;
+ QComboBox startupServerSelector;
+ QLabel startupRoomLabel;
+ QLineEdit *startupRoomNameEdit;
+
+ QLabel deckPathLabel;
+ QLabel filtersPathLabel;
+ QLabel replaysPathLabel;
+ QLabel picsPathLabel;
+ QLabel cardDatabasePathLabel;
+ QLabel customCardDatabasePathLabel;
+ QLabel tokenDatabasePathLabel;
QLineEdit *deckPathEdit;
QLineEdit *filtersPathEdit;
QLineEdit *replaysPathEdit;
@@ -51,33 +82,6 @@ private:
QLineEdit *tokenDatabasePathEdit;
QPushButton *resetAllPathsButton;
QLabel *allPathsResetLabel;
- QComboBox languageBox;
- QCheckBox startupUpdateCheckCheckBox;
- QLabel startupCardUpdateCheckBehaviorLabel;
- QComboBox startupCardUpdateCheckBehaviorSelector;
- QLabel cardUpdateCheckIntervalLabel;
- QSpinBox cardUpdateCheckIntervalSpinBox;
- QLabel lastCardUpdateCheckDateLabel;
- QCheckBox updateNotificationCheckBox;
- QCheckBox newVersionOracleCheckBox;
- QComboBox updateReleaseChannelBox;
- QLabel languageLabel;
- QLabel deckPathLabel;
- QLabel filtersPathLabel;
- QLabel replaysPathLabel;
- QLabel picsPathLabel;
- QLabel cardDatabasePathLabel;
- QLabel customCardDatabasePathLabel;
- QLabel tokenDatabasePathLabel;
- QLabel updateReleaseChannelLabel;
- QLabel advertiseTranslationPageLabel;
- QCheckBox showTipsOnStartup;
- QLabel startupTabLabel;
- QComboBox startupTabSelector;
- QLabel startupServerLabel;
- QComboBox startupServerSelector;
- QLabel startupRoomLabel;
- QLineEdit *startupRoomNameEdit;
};
#endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H
diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp
index e4f24ab73..c161030d8 100644
--- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp
+++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp
@@ -59,6 +59,10 @@ MessagesSettingsPage::MessagesSettingsPage()
connect(&roomHistory, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(),
&ChatSettings::setRoomHistory);
+ ignoreAllPrivateMessagesCheckBox.setChecked(SettingsCache::instance().chat().getIgnoreAllPrivateMessages());
+ connect(&ignoreAllPrivateMessagesCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(),
+ &ChatSettings::setIgnoreAllPrivateMessages);
+
customAlertString = new QLineEdit();
customAlertString->setText(SettingsCache::instance().chat().getHighlightWords());
connect(customAlertString, &QLineEdit::textChanged, &SettingsCache::instance().chat(),
@@ -76,6 +80,7 @@ MessagesSettingsPage::MessagesSettingsPage()
chatGrid->addWidget(&messagePopups, 5, 0);
chatGrid->addWidget(&mentionPopups, 6, 0);
chatGrid->addWidget(&roomHistory, 7, 0);
+ chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0);
chatGroupBox = new QGroupBox;
chatGroupBox->setLayout(chatGrid);
@@ -256,6 +261,7 @@ void MessagesSettingsPage::retranslateUi()
messagePopups.setText(tr("Enable desktop notifications for private messages"));
mentionPopups.setText(tr("Enable desktop notification for mentions"));
roomHistory.setText(tr("Enable room message history on join"));
+ ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages"));
hexLabel.setText(tr("(Color is hexadecimal)"));
hexHighlightLabel.setText(tr("(Color is hexadecimal)"));
customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only"));
diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h
index e98ae0592..436ebbad9 100644
--- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h
+++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h
@@ -40,6 +40,7 @@ private:
QCheckBox messagePopups;
QCheckBox mentionPopups;
QCheckBox roomHistory;
+ QCheckBox ignoreAllPrivateMessagesCheckBox;
QGroupBox *chatGroupBox;
QGroupBox *highlightGroupBox;
QGroupBox *messageGroupBox;
diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp
index 182e75aac..2c6e062da 100644
--- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp
+++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp
@@ -20,26 +20,7 @@ enum visualDeckStoragePromptForConversionIndex
UserInterfaceSettingsPage::UserInterfaceSettingsPage()
{
- // general settings and notification settings
- notificationsEnabledCheckBox.setChecked(SettingsCache::instance().userInterface().getNotificationsEnabled());
- connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
- &InterfaceSettings::setNotificationsEnabled);
- connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
- &UserInterfaceSettingsPage::setNotificationEnabled);
-
- specNotificationsEnabledCheckBox.setChecked(
- SettingsCache::instance().userInterface().getSpectatorNotificationsEnabled());
- specNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().userInterface().getNotificationsEnabled());
- connect(&specNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
- &InterfaceSettings::setSpectatorNotificationsEnabled);
-
- buddyConnectNotificationsEnabledCheckBox.setChecked(
- SettingsCache::instance().userInterface().getBuddyConnectNotificationsEnabled());
- buddyConnectNotificationsEnabledCheckBox.setEnabled(
- SettingsCache::instance().userInterface().getNotificationsEnabled());
- connect(&buddyConnectNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED,
- &SettingsCache::instance().userInterface(), &InterfaceSettings::setBuddyConnectNotificationsEnabled);
-
+ // general settings
doubleClickToPlayCheckBox.setChecked(SettingsCache::instance().userInterface().getDoubleClickToPlay());
connect(&doubleClickToPlayCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setDoubleClickToPlay);
@@ -103,6 +84,26 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
generalGroupBox = new QGroupBox;
generalGroupBox->setLayout(generalGrid);
+ // notification settings
+ notificationsEnabledCheckBox.setChecked(SettingsCache::instance().userInterface().getNotificationsEnabled());
+ connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
+ &InterfaceSettings::setNotificationsEnabled);
+ connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, this,
+ &UserInterfaceSettingsPage::setNotificationEnabled);
+
+ specNotificationsEnabledCheckBox.setChecked(
+ SettingsCache::instance().userInterface().getSpectatorNotificationsEnabled());
+ specNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().userInterface().getNotificationsEnabled());
+ connect(&specNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
+ &InterfaceSettings::setSpectatorNotificationsEnabled);
+
+ buddyConnectNotificationsEnabledCheckBox.setChecked(
+ SettingsCache::instance().userInterface().getBuddyConnectNotificationsEnabled());
+ buddyConnectNotificationsEnabledCheckBox.setEnabled(
+ SettingsCache::instance().userInterface().getNotificationsEnabled());
+ connect(&buddyConnectNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED,
+ &SettingsCache::instance().userInterface(), &InterfaceSettings::setBuddyConnectNotificationsEnabled);
+
auto *notificationsGrid = new QGridLayout;
notificationsGrid->addWidget(¬ificationsEnabledCheckBox, 0, 0);
notificationsGrid->addWidget(&specNotificationsEnabledCheckBox, 1, 0);
@@ -355,6 +356,7 @@ void UserInterfaceSettingsPage::retranslateUi()
notificationsEnabledCheckBox.setText(tr("Enable notifications in taskbar"));
specNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar for game events while you are spectating"));
buddyConnectNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar when users in your buddy list connect"));
+
animationGroupBox->setTitle(tr("Animation settings"));
enableAllAnimationsButton.setText(tr("&Enable all animations"));
disableAllAnimationsButton.setText(tr("&Disable all animations"));
@@ -362,6 +364,7 @@ void UserInterfaceSettingsPage::retranslateUi()
arrowDrawAnimationCheckBox.setText(tr("&Arrow draw animation"));
lifeCounterAnimationsCheckBox.setText(tr("Life counter flash"));
battlefieldFlashCheckBox.setText(tr("Battlefield flash on damage"));
+
deckEditorGroupBox->setTitle(tr("Deck editor/storage settings"));
openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default"));
visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby"));
@@ -397,8 +400,8 @@ void UserInterfaceSettingsPage::retranslateUi()
0, CommanderBracketNames::CommanderSpellbookBracketNames);
commanderSpellbookIntegrationBracketNamingSelector.setItemText(
1, CommanderBracketNames::OfficialCommanderBracketNames);
-
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setToolTip(CommanderBracketNames::Explainer);
+
replayGroupBox->setTitle(tr("Replay settings"));
rewindBufferingMsLabel.setText(tr("Buffer time for backwards skip via shortcut:"));
rewindBufferingMsBox.setSuffix(" ms");
diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h
index 0dc4cf4e8..e8a30fb1f 100644
--- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h
+++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h
@@ -23,9 +23,6 @@ private slots:
void updateCommanderSpellbookUiState();
private:
- QCheckBox notificationsEnabledCheckBox;
- QCheckBox specNotificationsEnabledCheckBox;
- QCheckBox buddyConnectNotificationsEnabledCheckBox;
QCheckBox doubleClickToPlayCheckBox;
QCheckBox clickPlaysAllSelectedCheckBox;
QCheckBox playToStackCheckBox;
@@ -37,12 +34,18 @@ private:
QCheckBox showTotalSelectionCountCheckBox;
QCheckBox useTearOffMenusCheckBox;
QCheckBox keepGameChatFocusCheckBox;
+
+ QCheckBox notificationsEnabledCheckBox;
+ QCheckBox specNotificationsEnabledCheckBox;
+ QCheckBox buddyConnectNotificationsEnabledCheckBox;
+
QPushButton enableAllAnimationsButton;
QPushButton disableAllAnimationsButton;
QCheckBox tapAnimationCheckBox;
QCheckBox arrowDrawAnimationCheckBox;
QCheckBox lifeCounterAnimationsCheckBox;
QCheckBox battlefieldFlashCheckBox;
+
QCheckBox openDeckInNewTabCheckBox;
QLabel visualDeckStoragePromptForConversionLabel;
QComboBox visualDeckStoragePromptForConversionSelector;
@@ -57,8 +60,10 @@ private:
QLabel commanderSpellbookIntegrationUseOfficialBracketNamesLabel;
QToolButton commanderSpellbookIntegrationUseOfficialBracketNamesExplainer;
QComboBox commanderSpellbookIntegrationBracketNamingSelector;
+
QLabel rewindBufferingMsLabel;
QSpinBox rewindBufferingMsBox;
+
QGroupBox *generalGroupBox;
QGroupBox *notificationsGroupBox;
QGroupBox *animationGroupBox;
diff --git a/cockatrice/src/interface/widgets/tabs/tab_account.cpp b/cockatrice/src/interface/widgets/tabs/tab_account.cpp
index 410a48d40..dbcf50966 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_account.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_account.cpp
@@ -137,6 +137,11 @@ void TabAccount::retranslateUi()
buddyList->retranslateUi();
ignoreList->retranslateUi();
userInfoBox->retranslateUi();
+
+ buddyList->setToolTip(tr("Buddies are marked with a star in chat, a sound plays when they join or leave the "
+ "server, and they can be invited to buddy-only games."));
+ ignoreList->setToolTip(tr("Ignored users' chat messages are hidden from you, and they cannot send you private "
+ "messages or join your games."));
}
void TabAccount::processListUsersResponse(const Response &response)
diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp
index 196ea4526..035ab1004 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp
@@ -266,6 +266,10 @@ void TabGame::resetChatAndPhase()
// reset phase markers
game->getGameState()->setCurrentPhase(-1);
+
+ // reset spectator state so the replay can rebuild it from the start
+ game->getPlayerManager()->clearSpectators();
+ playerListWidget->clearSpectators();
}
void TabGame::emitUserEvent()
diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp
index 9506d96f3..418843178 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp
@@ -98,6 +98,12 @@ void TabMessage::closeEvent(QCloseEvent *event)
void TabMessage::sendPrivateMessage(const QString &text)
{
+ if (tabSupervisor->getUserListManager()->isUserIgnored(getUserName())) {
+ chatView->appendMessage(tr("You have ignored %1; your messages are not delivered.")
+ .arg(QString::fromStdString(otherUserInfo->name())));
+ return;
+ }
+
Command_Message cmd;
cmd.set_user_name(otherUserInfo->name());
cmd.set_message(text.toStdString());
diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp
index 1b61bf80f..bb8ec719e 100644
--- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp
+++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp
@@ -117,9 +117,10 @@ void CloseButton::paintEvent(QPaintEvent * /*event*/)
}
TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent)
- : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabVisualDeckStorage(nullptr),
- tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr),
- tabLog(nullptr), tabReport(nullptr), tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false)
+ : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr),
+ tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr),
+ tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr),
+ tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false)
{
setElideMode(Qt::ElideRight);
setMovable(true);
@@ -250,6 +251,7 @@ void TabSupervisor::retranslateUi()
aTabLog->setText(tr("Logs"));
aTabReport->setText(tr("Report Queue"));
aTabModeration->setText(tr("Moderation"));
+ aTabCardArtRules->setText(tr("Card Art Rules"));
aTabDeveloper->setText(tr("Developer"));
// tabs
@@ -262,6 +264,7 @@ void TabSupervisor::retranslateUi()
tabs.append(tabLog);
tabs.append(tabReport);
tabs.append(tabModeration);
+ tabs.append(tabCardArtRules);
tabs.append(tabDeveloper);
QMapIterator roomIterator(roomTabs);
while (roomIterator.hasNext()) {
@@ -527,7 +530,9 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
if (SettingsCache::instance().tabs().getTabModerationOpen()) {
openTabModeration();
}
- openTabCardArtRules();
+ if (SettingsCache::instance().tabs().getTabCardArtRulesOpen()) {
+ openTabCardArtRules();
+ }
}
if (userInfo->user_level() & ServerInfo_User::IsDeveloper) {
@@ -603,6 +608,9 @@ void TabSupervisor::stop()
if (tabModeration) {
tabModeration->close();
}
+ if (tabCardArtRules) {
+ tabCardArtRules->close();
+ }
if (tabDeveloper) {
tabDeveloper->close();
}
@@ -799,6 +807,7 @@ void TabSupervisor::openTabAdmin()
void TabSupervisor::actTabCardArtRules(bool checked)
{
+ SettingsCache::instance().tabs().setTabCardArtRulesOpen(checked);
if (checked && !tabCardArtRules) {
openTabCardArtRules();
setCurrentWidget(tabCardArtRules);
@@ -1105,6 +1114,13 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus
return tab;
}
+ if (focus && userListManager->isUserIgnored(receiverName)) {
+ QMessageBox::information(
+ this, tr("Ignored user"),
+ tr("You have ignored %1. Remove them from your ignore list to open a private chat.").arg(receiverName));
+ return nullptr;
+ }
+
tab = new TabMessage(this, client, *userInfo, otherUser, userOnline);
connect(tab, &TabMessage::talkClosing, this, &TabSupervisor::talkLeft);
connect(tab, &TabMessage::maximizeClient, this, &TabSupervisor::maximizeMainWindow);
@@ -1319,7 +1335,21 @@ void TabSupervisor::processGameEventContainer(const GameEventContainer &cont)
void TabSupervisor::processUserMessageEvent(const Event_UserMessage &event)
{
+ // "Ignore all private messages" silences every PM, including messages to
+ // already-open tabs — unlike the unregistered/non-buddy filters below,
+ // which only apply when creating a new tab. Messages from moderators/admins
+ // are exempt to ensure warnings still reach users.
QString senderName = QString::fromStdString(event.sender_name());
+ if (SettingsCache::instance().chat().getIgnoreAllPrivateMessages()) {
+ const ServerInfo_User *onlineUserInfo = userListManager->getOnlineUser(senderName);
+ if (!onlineUserInfo) {
+ return;
+ }
+ const UserLevelFlags userLevel(onlineUserInfo->user_level());
+ if (!userLevel.testFlag(ServerInfo_User::IsModerator) && !userLevel.testFlag(ServerInfo_User::IsAdmin)) {
+ return;
+ }
+ }
TabMessage *tab = messageTabs.value(senderName);
if (!tab) {
tab = messageTabs.value(QString::fromStdString(event.receiver_name()));
diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp
index 209a30642..0f43893d3 100644
--- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp
+++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp
@@ -4,6 +4,7 @@
#include "../../../../client/settings/shortcuts_settings.h"
#include "../../cards/card_info_display_widget.h"
#include "../../deck_editor/deck_state_manager.h"
+#include "../../deck_editor/deck_zone_dialog.h"
#include "../../filters/filter_builder.h"
#include "../../interface/pixel_map_generator.h"
#include "../../interface/widgets/cards/card_info_frame_widget.h"
@@ -84,6 +85,7 @@ void TabDeckEditorVisual::createCentralFrame()
connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this,
&TabDeckEditorVisual::showPrintingSelector);
connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo);
+ tabContainer->visualDatabaseDisplay->setNewZoneCreator([this] { return createNewZone(); });
centralFrame->addWidget(tabContainer);
setCentralWidget(centralWidget);
@@ -269,6 +271,19 @@ bool TabDeckEditorVisual::actSaveDeckAs()
return result;
}
+/** @brief Prompts for and creates a new custom deck zone. Returns the name of the created zone. */
+QString TabDeckEditorVisual::createNewZone()
+{
+ QString boardName;
+ const QString zoneName = DeckZoneDialog::promptForNewZone(this, {}, &boardName, [this](const QString &candidate) {
+ return deckStateManager->validateNewZoneName(candidate);
+ });
+ if (!zoneName.isEmpty()) {
+ deckStateManager->createCustomZone(boardName, zoneName);
+ }
+ return zoneName;
+}
+
/** @brief Refreshes keyboard shortcuts for this tab from settings. */
void TabDeckEditorVisual::refreshShortcuts()
{
diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h
index 21335d2d0..fb09578c4 100644
--- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h
+++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h
@@ -165,6 +165,12 @@ public slots:
*/
bool actSaveDeckAs() override;
+ /**
+ * @brief Prompts for and creates a new custom deck zone.
+ * @return The name of the created zone, or an empty string if creation was cancelled.
+ */
+ QString createNewZone();
+
private:
/**
* @brief Sets the deck for this tab and selects the sub-tab to open on
diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp
index 0cdf60d5d..76bbf344b 100644
--- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp
+++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp
@@ -21,6 +21,7 @@
#include
#include
#include
+#include
#include
#include
@@ -89,6 +90,19 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
databaseView->setItemDelegate(nullptr);
databaseView->setVisible(false);
+ // Without a deck model there is nothing to add cards to, so the zone menu stays hidden.
+ if (deckListModel) {
+ databaseView->setZoneMenuProvider(
+ [deckListModel]() -> QList> {
+ QList> result;
+ for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
+ result.append({boardName, deckListModel->getCustomZoneNames(boardName)});
+ }
+ return result;
+ },
+ [this] { return newZoneCreator ? newZoneCreator() : QString(); });
+ }
+
searchEdit->setTreeView(databaseView);
searchEdit->installEventFilter(databaseView->getKeySignals());
@@ -195,6 +209,11 @@ void VisualDatabaseDisplayWidget::showEvent(QShowEvent *event)
initializeFilters();
}
+void VisualDatabaseDisplayWidget::setNewZoneCreator(const std::function &creator)
+{
+ newZoneCreator = creator;
+}
+
void VisualDatabaseDisplayWidget::retranslateUi()
{
databaseLoadIndicator->setText(tr("Loading database ..."));
diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h
index 6e4d87876..d161ce362 100644
--- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h
+++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h
@@ -22,6 +22,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -46,6 +47,12 @@ public:
void sortCardList(const QStringList &properties, Qt::SortOrder order) const;
void setDeckList(const DeckList &new_deck_list_model);
+ /**
+ * @brief Sets the callback used to create a custom zone from the add-to-zone menu.
+ * The callback returns the name of the created zone, or an empty string if creation was cancelled.
+ */
+ void setNewZoneCreator(const std::function &creator);
+
CardDatabaseDisplayModel *getDatabaseDisplayModel()
{
return databaseDisplayModel;
@@ -106,6 +113,7 @@ private:
VisualDatabaseDisplayFilterToolbarWidget *filterContainer;
CardDatabaseDisplayModel *databaseDisplayModel;
CardDatabaseView *databaseView;
+ std::function newZoneCreator;
QList *cards;
QVBoxLayout *mainLayout;
QScrollArea *scrollArea;
diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp
index fbaabf90f..22d73b604 100644
--- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp
+++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp
@@ -125,9 +125,7 @@ void VisualDeckStorageFolderDisplayWidget::continueDeckPass()
}
const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool();
- if (matches == deckPreviewWidget->isHidden()) {
- deckPreviewWidget->setVisible(matches);
- }
+ deckPreviewWidget->setVisible(matches);
if (matches) {
++visibleDeckCount;
}
diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp
index 4567991c8..43f1de9dd 100644
--- a/cockatrice/src/interface/window_main.cpp
+++ b/cockatrice/src/interface/window_main.cpp
@@ -682,6 +682,7 @@ void MainWindow::runFirstRunWizard()
connect(wizard, &FirstRunWizard::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdatesBackground);
connect(wizard, &FirstRunWizard::manualCardDatabaseSetupRequested, this, &MainWindow::actCheckCardUpdates);
connect(this, &MainWindow::cardDatabaseUpdateFinished, wizard, &FirstRunWizard::onCardDatabaseUpdateFinished);
+ connect(this, &MainWindow::cardDatabaseUpdateProgress, wizard, &FirstRunWizard::onCardDatabaseUpdateProgress);
connect(wizard, &FirstRunWizard::registerRequested, connectionController, &ConnectionController::registerToServer);
connect(wizard, &FirstRunWizard::connectRequested, connectionController, &ConnectionController::connectToServer);
@@ -843,6 +844,17 @@ void MainWindow::closeEvent(QCloseEvent *event)
}
bClosingDown = true;
+ if (cardUpdateProcess && cardUpdateProcess->state() != QProcess::NotRunning) {
+ if (QMessageBox::question(this, tr("Are you sure?"),
+ tr("A card database update is still running. Quitting now will cancel it.\n"
+ "Are you sure you want to quit?"),
+ QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) {
+ event->ignore();
+ bClosingDown = false;
+ return;
+ }
+ }
+
if (!tabSupervisor->close()) {
event->ignore();
bClosingDown = false;
@@ -1057,11 +1069,45 @@ void MainWindow::createCardUpdateProcess(bool background)
if (!background) {
cardUpdateProcess->start(updaterCmd, QStringList());
} else {
+ cardUpdateOutputBuffer.clear();
+ connect(cardUpdateProcess, &QProcess::readyReadStandardOutput, this, &MainWindow::cardUpdateProgressOutput);
cardUpdateProcess->start(updaterCmd, QStringList("-b"));
statusBar()->showMessage(tr("Card database update running."));
}
}
+void MainWindow::cardUpdateProgressOutput()
+{
+ if (!cardUpdateProcess) {
+ return;
+ }
+ cardUpdateOutputBuffer.append(cardUpdateProcess->readAllStandardOutput());
+ while (true) {
+ const int newline = cardUpdateOutputBuffer.indexOf('\n');
+ if (newline < 0) {
+ break;
+ }
+ const QByteArray line = cardUpdateOutputBuffer.left(newline).trimmed();
+ cardUpdateOutputBuffer.remove(0, newline + 1);
+ // Protocol emitted by `oracle -b`: "PROGRESS "
+ if (!line.startsWith("PROGRESS ")) {
+ continue;
+ }
+ const QList parts = line.split(' ');
+ if (parts.size() != 4) {
+ continue;
+ }
+ bool doneOk = false;
+ bool totalOk = false;
+ const qint64 done = parts.at(2).toLongLong(&doneOk);
+ const qint64 total = parts.at(3).toLongLong(&totalOk);
+ if (!doneOk || !totalOk || done < 0 || total < 0) {
+ continue;
+ }
+ emit cardDatabaseUpdateProgress(QString::fromLatin1(parts.at(1)), done, total);
+ }
+}
+
void MainWindow::exitCardDatabaseUpdate()
{
if (!cardUpdateProcess) {
@@ -1109,6 +1155,8 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err)
void MainWindow::cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus)
{
+ cardUpdateProgressOutput(); // drain any progress lines not yet parsed
+
const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0);
if (exitStatus == QProcess::NormalExit) {
SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date());
diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h
index 920145552..08481fd36 100644
--- a/cockatrice/src/interface/window_main.h
+++ b/cockatrice/src/interface/window_main.h
@@ -68,6 +68,11 @@ signals:
/** @brief Emitted after the background card-database update subprocess exits. */
void cardDatabaseUpdateFinished(bool success);
+ /** @brief Emitted while the background card-database update subprocess runs.
+ * @p stage is one of "download", "scan" or "import"; @p done/@p total
+ * are byte counts for the first two stages and set indices for "import". */
+ void cardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total);
+
public slots:
void actCheckCardUpdates();
void actCheckCardUpdatesBackground();
@@ -96,6 +101,7 @@ private slots:
void cardUpdateError(QProcess::ProcessError err);
void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus);
+ void cardUpdateProgressOutput();
void refreshShortcuts();
void cardDatabaseLoadingFailed();
void cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknownSetsNames);
@@ -159,6 +165,7 @@ private:
LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph
bool bHasActivated, askedForDbUpdater;
QProcess *cardUpdateProcess;
+ QByteArray cardUpdateOutputBuffer;
DlgViewLog *logviewDialog;
GameReplay *replay;
DlgTipOfTheDay *tip;
diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp
index 84d5d175f..d8aa1cd08 100644
--- a/cockatrice/src/main.cpp
+++ b/cockatrice/src/main.cpp
@@ -53,6 +53,7 @@
#include
#include
#include
+#include
QTranslator *translator, *qtTranslator;
RNG_Abstract *rng;
@@ -292,7 +293,7 @@ int main(int argc, char *argv[])
}
}
- rng = new RNG_SFMT;
+ rng = new RNG_SFMT(CryptoUtil::randomUInt64());
themeManager = new ThemeManager;
soundEngine = new SoundEngine;
diff --git a/format.sh b/format.sh
index 3fa435be1..9e3a6069b 100755
--- a/format.sh
+++ b/format.sh
@@ -18,11 +18,11 @@ include=("cockatrice/src" \
libcockatrice_* \
"oracle/src" \
"servatrice/src" \
+"cmake/pch" \
"tests")
exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \
"libcockatrice_utility/libcockatrice/utility/peglib.h" \
"oracle/src/lzma/" \
-"oracle/src/qt-json/" \
"oracle/src/zip/" \
"servatrice/src/smtp/")
exts=("cpp" "h" "proto")
diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h
index af1193f26..5d91cd233 100644
--- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h
+++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h
@@ -115,6 +115,25 @@ public:
*/
QList getCustomZones(const QString &boardZoneName) const;
+ /**
+ * @brief Checks whether a zone name is taken anywhere in the deck.
+ *
+ * Covers the standard board names and any top-level or nested custom zone.
+ * @param zoneName The checked name.
+ * @return true if the name is reserved or already in use.
+ */
+ bool hasZoneName(const QString &zoneName) const;
+
+ /**
+ * @brief Finds a custom zone anywhere in the deck by name.
+ *
+ * Walks the children of every top-level zone, so a zone nested under any
+ * board (and not just the standard ones) is found.
+ * @param zoneName The zone name to find.
+ * @return The matching zone node, or nullptr if none exists.
+ */
+ InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const;
+
/**
* @brief Applies a function to every card in the deck tree. This can modify the cards.
*
@@ -128,8 +147,6 @@ private:
InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const;
InnerDecklistNode *findBoardZone(const QString &boardZoneName) const;
InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName);
- InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const;
- bool hasZoneName(const QString &zoneName) const;
};
#endif // COCKATRICE_DECKLIST_NODE_TREE_H
diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp
index ec860dc56..d082b3cca 100644
--- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp
+++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp
@@ -43,6 +43,13 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method)
}
}
+const QList &InnerDecklistNode::boardZoneNames()
+{
+ static const QList names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE),
+ QString(DECK_ZONE_MAYBEBOARD)};
+ return names;
+}
+
QString InnerDecklistNode::getVisibleName() const
{
return visibleNameFromName(name);
@@ -87,6 +94,9 @@ AbstractDecklistNode *InnerDecklistNode::findCardChildByNameProviderIdAndNumber(
int InnerDecklistNode::height() const
{
+ if (isEmpty()) {
+ return 1;
+ }
return at(0)->height() + 1;
}
diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h
index 906ed6cb5..0d454c11e 100644
--- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h
+++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h
@@ -18,6 +18,9 @@
#include "abstract_deck_list_node.h"
+#include
+#include
+
/** @brief Constant for the "main" deck zone name. */
#define DECK_ZONE_MAIN "main"
/** @brief Constant for the "sideboard" zone name. */
@@ -118,6 +121,13 @@ public:
*/
static QString visibleNameFromName(const QString &_name);
+ /**
+ * @brief The standard board zone names, in display order.
+ *
+ * @return main, side and maybeboard.
+ */
+ static const QList &boardZoneNames();
+
/**
* @brief Get this node’s display-friendly name.
* @return Human-readable name (zone/group name).
diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp
index 25e8e97db..aaf391c03 100644
--- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp
+++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp
@@ -5,6 +5,7 @@
#include
#include
#include
+#include
#include
static peg::parser search(R"(
@@ -19,7 +20,7 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart
QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery
NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart
-SetQuery <- ('e'/'set') [:] FlexStringValue
+SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue)
OracleQuery <- 'o' [:] MatcherString
@@ -64,6 +65,8 @@ RegexMatcherString <- ('\\/' / !'/' .)+
FlexStringValue <- CompactStringSet / String / [(] StringList [)]
CompactStringSet <- StringListString ([,+] StringListString)+
+SetExpression <- NumericOperator ws? String
+
NumericExpression <- NumericOperator ws? NumericValue
NumericOperator <- [=:] / <[>
NumericValue <- [0-9]+
@@ -101,12 +104,25 @@ static void setupParserRules()
return [=](const CardData &x) -> bool { return matcher(x->getCardType()); };
};
search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter {
- auto matcher = std::any_cast(sv[0]);
- return [=](const CardData &x) -> bool {
- QList sets = x->getSets().keys();
+ if (sv.choice() == 1) {
+ auto matcher = std::any_cast(sv[0]);
+ return [=](const CardData &x) -> bool {
+ QList sets = x->getSets().keys();
- auto matchesSet = [&matcher](const QString &set) { return matcher(set); };
- return std::any_of(sets.begin(), sets.end(), matchesSet);
+ auto matchesSet = [&matcher](const QString &set) { return matcher(set); };
+ return std::any_of(sets.begin(), sets.end(), matchesSet);
+ };
+ }
+
+ auto matcher = std::any_cast(sv[0]);
+ return [=](const CardData &x) -> bool {
+ const auto &sets = x->getSets().values();
+ auto matchesSet = [&](const PrintingInfo &printing) {
+ return printing.getSet()->getEnabled() && matcher(printing.getSet()->getReleaseDate().toJulianDay());
+ };
+ return std::any_of(sets.begin(), sets.end(), [&](const auto &printings) {
+ return std::any_of(printings.begin(), printings.end(), matchesSet);
+ });
};
};
search["Rarity"] = [](const peg::SemanticValues &sv) -> QString {
@@ -247,40 +263,54 @@ static void setupParserRules()
return QString::fromStdString(std::string(sv.sv()));
};
- search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
- const auto arg = std::any_cast(sv[1]);
- const auto op = std::any_cast(sv[0]);
+ search["NumericOperator"] = [](const peg::SemanticValues &sv) -> NumberComparer {
+ const auto op = QString::fromStdString(std::string(sv.sv()));
if (op == ">") {
- return [=](const int s) { return s > arg; };
+ return [=](const int s, const int arg) { return s > arg; };
}
if (op == ">=") {
- return [=](const int s) { return s >= arg; };
+ return [=](const int s, const int arg) { return s >= arg; };
}
if (op == "<") {
- return [=](const int s) { return s < arg; };
+ return [=](const int s, const int arg) { return s < arg; };
}
if (op == "<=") {
- return [=](const int s) { return s <= arg; };
+ return [=](const int s, const int arg) { return s <= arg; };
}
if (op == "=") {
- return [=](const int s) { return s == arg; };
+ return [=](const int s, const int arg) { return s == arg; };
}
if (op == ":") {
- return [=](const int s) { return s == arg; };
+ return [=](const int s, const int arg) { return s == arg; };
}
if (op == "!=") {
- return [=](const int s) { return s != arg; };
+ return [=](const int s, const int arg) { return s != arg; };
}
- return [](int) { return false; };
+ return [](int, int) { return false; };
};
search["NumericValue"] = [](const peg::SemanticValues &sv) -> int {
return QString::fromStdString(std::string(sv.sv())).toInt();
};
- search["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString {
- return QString::fromStdString(std::string(sv.sv()));
+ search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
+ const auto comparer = std::any_cast(sv[0]);
+ const auto arg = std::any_cast(sv[1]);
+ return [=](int s) { return comparer(s, arg); };
+ };
+
+ search["SetExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
+ const auto comparer = std::any_cast(sv[0]);
+ const auto setCode = std::any_cast(sv[1]);
+ const auto allSets = CardDatabaseManager::getInstance()->getSetList();
+ for (auto &set : allSets) {
+ if (set->getShortName() == setCode) {
+ const int releaseDate = set->getReleaseDate().toJulianDay();
+ return [=](int s) { return comparer(s, releaseDate); };
+ }
+ }
+ return [](int) { return false; };
};
search["NormalMatcher"] = [](const peg::SemanticValues &sv) -> StringMatcher {
diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.h b/libcockatrice_filters/libcockatrice/filters/filter_string.h
index 71a99f7b5..a058f7d07 100644
--- a/libcockatrice_filters/libcockatrice/filters/filter_string.h
+++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h
@@ -22,6 +22,7 @@ typedef CardInfoPtr CardData;
typedef std::function Filter;
typedef std::function StringMatcher;
typedef std::function NumberMatcher;
+typedef std::function NumberComparer;
namespace peg
{
diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h
index cd9ad29e1..cdf2da5eb 100644
--- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h
+++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h
@@ -20,6 +20,7 @@ public:
[[nodiscard]] virtual bool getShowMessagePopup() const = 0;
[[nodiscard]] virtual bool getShowMentionPopup() const = 0;
[[nodiscard]] virtual bool getRoomHistory() const = 0;
+ [[nodiscard]] virtual bool getIgnoreAllPrivateMessages() const = 0;
[[nodiscard]] virtual QString getHighlightWords() const = 0;
};
diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h
index a81616cb0..054c4cd72 100644
--- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h
+++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h
@@ -21,6 +21,7 @@ public:
[[nodiscard]] virtual bool getTabLogOpen() const = 0;
[[nodiscard]] virtual bool getTabReportOpen() const = 0;
[[nodiscard]] virtual bool getTabModerationOpen() const = 0;
+ [[nodiscard]] virtual bool getTabCardArtRulesOpen() const = 0;
};
#endif // COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H
diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt
index d4aee3686..a6ab2a204 100644
--- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt
+++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt
@@ -7,7 +7,8 @@ set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h)
qt6_wrap_cpp(MOC_SOURCES ${HEADERS})
add_library(
- libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_sort_filter_proxy_model.cpp
+ libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp
+ deck_list_sort_filter_proxy_model.cpp
)
target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp
index 9b43281c1..76afca0c4 100644
--- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp
+++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp
@@ -66,7 +66,8 @@ void DeckListModel::rebuildTree()
for (int j = 0; j < currentZone->size(); j++) {
auto *currentCard = dynamic_cast(currentZone->at(j));
- //! \todo Better sanity checking.
+ // Non-card children are custom zones; they are mirrored in a single
+ // pass below so each is mirrored exactly once.
if (currentCard == nullptr) {
continue;
}
@@ -82,8 +83,19 @@ void DeckListModel::rebuildTree()
new DecklistModelCardNode(currentCard, groupNode);
}
+
+ // Custom zones nested under the board zone are mirrored as-is, with their
+ // cards as direct children (no further grouping).
+ DeckListModelCustomZones::mirrorCustomZones(currentZone, node);
}
+ // The shadow tree was built in deck file order. Apply the active sort while
+ // the reset is still open so every consumer (tree view and visual editor)
+ // sees the canonical order from the start. sortShadowTree emits no signals,
+ // which is only valid before endResetModel closes the reset.
+ root->setSortMethod(lastKnownColumn == 0 ? DeckSortMethod::ByNumber : DeckSortMethod::ByName);
+ sortShadowTree(root, lastKnownOrder);
+
endResetModel();
refreshCardFormatLegalities();
@@ -154,6 +166,9 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
case DeckRoles::IsLegalRole:
return true;
+ case DeckRoles::IsCustomZoneRole:
+ return DeckListModelCustomZones::isCustomZone(group);
+
default:
return {};
}
@@ -190,6 +205,10 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
return card->getFormatLegality();
}
+ case DeckRoles::IsCustomZoneRole: {
+ return false;
+ }
+
default: {
return {};
}
@@ -327,6 +346,13 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
return false;
}
+ // Custom zone rows are managed through the deck tree, never removed as model rows.
+ for (int i = 0; i < count; i++) {
+ if (DeckListModelCustomZones::isCustomZone(node->at(row + i))) {
+ return false;
+ }
+ }
+
beginRemoveRows(parent, row, row + count - 1);
for (int i = 0; i < count; i++) {
AbstractDecklistNode *toDelete = node->takeAt(row);
@@ -337,7 +363,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
}
endRemoveRows();
- if (node->empty() && (node != root)) {
+ // Empty criteria groups get pruned, but custom zones stay until explicitly deleted.
+ if (node->empty() && (node != root) && !DeckListModelCustomZones::isCustomZone(node)) {
removeRows(parent.row(), 1, parent.parent());
} else {
emitRecursiveUpdates(parent);
@@ -351,7 +378,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent)
{
- auto *newNode = dynamic_cast(parent->findChild(name));
+ // Group lookups must not resolve a mirrored custom zone that shares the name.
+ auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name);
if (!newNode) {
beginInsertRows(nodeToIndex(parent), parent->size(), parent->size());
newNode = new InnerDecklistNode(name, parent);
@@ -365,24 +393,44 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName,
const QString &providerId,
const QString &cardNumber) const
{
- InnerDecklistNode *zoneNode = dynamic_cast(root->findChild(zoneName));
- if (!zoneNode) {
- return nullptr;
- }
-
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName);
if (!info) {
return nullptr;
}
- QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria);
- InnerDecklistNode *groupNode = dynamic_cast(zoneNode->findChild(groupCriteria));
- if (!groupNode) {
- return nullptr;
+ // 1. Board zone lookup: search the criteria groups, then the custom zones
+ // nested under the board.
+ if (auto *zoneNode = dynamic_cast(root->findChild(zoneName))) {
+ QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria);
+ if (auto *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) {
+ if (auto *card = dynamic_cast(
+ groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
+ return card;
+ }
+ }
+
+ for (auto *child : *zoneNode) {
+ if (!DeckListModelCustomZones::isCustomZone(child)) {
+ continue;
+ }
+ auto *customZone = dynamic_cast(child);
+ if (!customZone) {
+ continue;
+ }
+ if (auto *card = dynamic_cast(
+ customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
+ return card;
+ }
+ }
}
- return dynamic_cast(
- groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
+ // 2. Custom zone lookup by name (custom zone names are deck-unique).
+ if (auto *customZone = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) {
+ return dynamic_cast(
+ customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
+ }
+
+ return nullptr;
}
QModelIndex DeckListModel::findCard(const QString &cardName,
@@ -423,29 +471,95 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam
return {};
}
- InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root);
-
CardInfoPtr cardInfo = card.getCardPtr();
PrintingInfo printingInfo = card.getPrinting();
- QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
- InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode);
+ InnerDecklistNode *cardParent = nullptr;
- const QModelIndex parentIndex = nodeToIndex(groupNode);
- auto *cardNode = dynamic_cast(groupNode->findCardChildByNameProviderIdAndNumber(
+ auto *boardNode = dynamic_cast(root->findChild(zoneName));
+ auto *customZoneNode = boardNode ? nullptr : DeckListModelCustomZones::findSubZoneByName(root, zoneName);
+
+ // Mirroring flattens nested deck sub-zones into shadow rows, so a shadow row
+ // index is only usable as a deck-tree position while both sides have the same
+ // direct-children shape. When they diverge, the card is appended to the deck
+ // zone instead of being written out of range.
+ InnerDecklistNode *deckCardParent = nullptr;
+ bool customZoneNeedsAppend = false;
+
+ if (boardNode) {
+ // Board zone: cards are grouped by the active criteria.
+ QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
+ cardParent = createNodeIfNeeded(groupCriteria, boardNode);
+ } else if (customZoneNode) {
+ // Custom zone: cards live flat inside the zone.
+ cardParent = customZoneNode;
+ auto *listRoot = deckList->getTree()->getRoot();
+ for (int i = 0; i < listRoot->size(); ++i) {
+ auto *boardZone = dynamic_cast(listRoot->at(i));
+ if (!boardZone) {
+ continue;
+ }
+ deckCardParent = dynamic_cast(boardZone->findChild(zoneName));
+ if (deckCardParent) {
+ break;
+ }
+ }
+ // A deck custom zone holding nested sub-zones mirrors with flattened rows,
+ // so a shadow row index does not map onto its direct children.
+ if (deckCardParent) {
+ for (int i = 0; i < deckCardParent->size(); ++i) {
+ if (dynamic_cast(deckCardParent->at(i))) {
+ customZoneNeedsAppend = true;
+ break;
+ }
+ }
+ }
+ } else {
+ // Not present in the shadow tree. The deck tree may still hold a custom
+ // zone that has not been mirrored (callers can add a zone and then a
+ // card without a rebuild). Check before falling back to creating a
+ // top-level zone the deck does not actually have.
+ auto *listRoot = deckList->getTree()->getRoot();
+ bool hasDeckZone = false;
+ for (int i = 0; i < listRoot->size(); ++i) {
+ if (auto *boardZone = dynamic_cast(listRoot->at(i))) {
+ // Only real zones count: a card sitting directly under the board
+ // shares the name comparison but is not a zone, and treating it as
+ // one would recurse forever without mirroring anything.
+ if (dynamic_cast(boardZone->findChild(zoneName))) {
+ hasDeckZone = true;
+ break;
+ }
+ }
+ }
+
+ if (hasDeckZone) {
+ rebuildTree();
+ return addCard(card, zoneName);
+ }
+
+ // Unknown zone: create a top-level zone (legacy behavior).
+ QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria);
+ auto *newZone = createNodeIfNeeded(zoneName, root);
+ cardParent = createNodeIfNeeded(groupCriteria, newZone);
+ }
+
+ const QModelIndex parentIndex = nodeToIndex(cardParent);
+ auto *cardNode = dynamic_cast(cardParent->findCardChildByNameProviderIdAndNumber(
card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num")));
const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName();
bool cardNodeAdded = false;
if (!cardNode) {
// Determine the correct index
- int insertRow = findSortedInsertRow(groupNode, cardInfo);
+ int insertRow = findSortedInsertRow(cardParent, cardInfo);
+ int deckInsertRow = customZoneNeedsAppend ? -1 : insertRow;
- auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName,
+ auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, deckInsertRow, cardSetName,
printingInfo.getProperty("num"), printingInfo.getProperty("uuid"));
beginInsertRows(parentIndex, insertRow, insertRow);
- cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow);
+ cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow);
endInsertRows();
cardNodeAdded = true;
@@ -576,21 +690,41 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const
return createIndex(node->getParent()->indexOf(node), 0, node);
}
+/**
+ * @brief Sorts a freshly built shadow subtree without emitting model signals.
+ *
+ * Used by rebuildTree while the model reset is still open (emitting layout
+ * changes during a reset is invalid). Reorders every node just like
+ * sortHelper does, but ignores the movement mapping because there are no
+ * persistent indices established yet.
+ */
+void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order)
+{
+ // The mapping is not needed: fresh shadow nodes have no persistent indices yet.
+ (void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order);
+
+ for (int i = node->size() - 1; i >= 0; --i) {
+ if (auto *subNode = dynamic_cast(node->at(i))) {
+ sortShadowTree(subNode, order);
+ }
+ }
+}
+
void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order)
{
- // Sort children of node and save the information needed to
- // update the list of persistent indexes.
- QVector> sortResult = node->sort(order);
+ // Sort children (custom zones always sorted after groups within a board) and
+ // use the movement mapping to update the list of persistent indices.
+ const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order);
QModelIndexList from, to;
int columns = columnCount();
- for (int i = sortResult.size() - 1; i >= 0; --i) {
- const int fromRow = sortResult[i].first;
- const int toRow = sortResult[i].second;
- AbstractDecklistNode *temp = node->at(toRow);
+ for (const auto &move : mapping) {
+ const int preSortRow = move.first;
+ const int finalRow = move.second;
+ AbstractDecklistNode *temp = node->at(finalRow);
for (int j = 0; j < columns; ++j) {
- from << createIndex(fromRow, j, temp);
- to << createIndex(toRow, j, temp);
+ from << createIndex(preSortRow, j, temp);
+ to << createIndex(finalRow, j, temp);
}
}
changePersistentIndexList(from, to);
@@ -704,6 +838,15 @@ QList DeckListModel::getZones() const
return zones;
}
+QStringList DeckListModel::getCustomZoneNames(const QString &boardZoneName) const
+{
+ QStringList zoneNames;
+ for (const auto *customZone : deckList->getTree()->getCustomZones(boardZoneName)) {
+ zoneNames.append(customZone->getName());
+ }
+ return zoneNames;
+}
+
static int maxAllowedForLegality(const FormatRules &format, const QString &legality)
{
for (const AllowedCount &c : format.allowedCounts) {
diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h
index 209ec8c42..09600ca67 100644
--- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h
+++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h
@@ -1,6 +1,8 @@
#ifndef DECKLISTMODEL_H
#define DECKLISTMODEL_H
+#include "deck_list_model_custom_zones.h"
+
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h>
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h>
#include
@@ -30,7 +32,8 @@ enum
{
IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */
DepthRole, /**< Depth level within the deck's grouping hierarchy. */
- IsLegalRole /**< Whether the card is legal in the current deck format. */
+ IsLegalRole, /**< Whether the card is legal in the current deck format. */
+ IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */
};
} // namespace DeckRoles
@@ -391,6 +394,14 @@ public:
*/
[[nodiscard]] QList getZones() const;
+ /**
+ * @brief Gets the names of the custom zones nested under the given board zone.
+ *
+ * @param boardZoneName The board zone to query (main/side/maybeboard)
+ * @return The custom zone names, in deck order
+ */
+ [[nodiscard]] QStringList getCustomZoneNames(const QString &boardZoneName) const;
+
private:
QSharedPointer deckList; /**< Pointer to the decklist providing the underlying data. */
InnerDecklistNode *root; /**< Root node of the model tree. */
@@ -427,6 +438,7 @@ private:
void emitRecursiveUpdates(const QModelIndex &index);
void sortHelper(InnerDecklistNode *node, Qt::SortOrder order);
+ void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order);
template