Merge branch 'master' into tooomm-qt5

This commit is contained in:
tooomm 2026-09-06 18:17:21 +02:00
commit 9766c9dcf8
117 changed files with 5448 additions and 1793 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -149,6 +149,9 @@ if [[ $MAKE_TEST ]]; then
fi fi
if [[ $USE_CCACHE ]]; then if [[ $USE_CCACHE ]]; then
flags+=("-DUSE_CCACHE=1") 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 if [[ $CCACHE_SIZE ]]; then
# This setting persists after running the script # This setting persists after running the script
ccache --max-size "$CCACHE_SIZE" ccache --max-size "$CCACHE_SIZE"

View file

@ -40,7 +40,7 @@ jobs:
steps: steps:
- name: "Checkout repository" - name: "Checkout repository"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Initialize CodeQL" - name: "Initialize CodeQL"
uses: github/codeql-action/init@v4 uses: github/codeql-action/init@v4

View file

@ -176,8 +176,12 @@ jobs:
shell: bash shell: bash
run: | run: |
source .ci/docker.sh source .ci/docker.sh
RUN --server --debug --test --ccache "$CCACHE_SIZE" \ args=()
--cmake-generator "$CMAKE_GENERATOR" [[ $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" - name: "Build release package"
id: build id: build

View file

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

View file

@ -7,20 +7,20 @@
cmake_minimum_required(VERSION 3.25) cmake_minimum_required(VERSION 3.25)
# Early detect ccache # Use compiler cache (ccache)
option(USE_CCACHE "Cache the build results with ccache" ON) option(USE_CCACHE "Cache the build results with ccache" ON)
# Treat warnings as errors (Debug builds only) # Treat warnings as errors (Debug builds only)
option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON) option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON)
# Check for translation updates # Check for translation updates
option(UPDATE_TRANSLATIONS "Update translations on compile" OFF) option(UPDATE_TRANSLATIONS "Update translations on compile" OFF)
# Compile servatrice # Compile Cockatrice
option(WITH_SERVER "build servatrice" OFF) option(WITH_CLIENT "Build Cockatrice client" ON)
# Compile cockatrice # Compile Oracle
option(WITH_CLIENT "build cockatrice" ON) option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON)
# Compile oracle # Compile Servatrice
option(WITH_ORACLE "build oracle" ON) option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF)
# Compile tests # Compile tests
option(TEST "build tests" OFF) option(TEST "Build tests" OFF)
# Use vcpkg regardless of OS # Use vcpkg regardless of OS
option(USE_VCPKG "Use vcpkg regardless of OS" OFF) option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
@ -38,13 +38,24 @@ else()
) )
endif() 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) find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM) if(CCACHE_PROGRAM)
# Support Unix Makefiles and Ninja # Support Unix Makefiles and Ninja
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}") 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}") message(STATUS "Found CCache ${CCACHE_PROGRAM}")
endif() 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() endif()
if(WIN32 OR USE_VCPKG) # Windows (including 64bit) or USE_VCPKG set if(WIN32 OR USE_VCPKG) # Windows (including 64bit) or USE_VCPKG set
@ -166,6 +177,9 @@ elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}") set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}")
endif() endif()
endforeach() endforeach()
# Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
else() else()
# other: osx/llvm, bsd/llvm # other: osx/llvm, bsd/llvm
set(CMAKE_CXX_FLAGS_RELEASE "-O2") set(CMAKE_CXX_FLAGS_RELEASE "-O2")
@ -174,6 +188,9 @@ else()
else() else()
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra") set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra")
endif() endif()
# Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
endif() endif()
# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning
@ -206,11 +223,6 @@ if(WIN32)
find_package(OpenSSL REQUIRED) find_package(OpenSSL REQUIRED)
if(OPENSSL_FOUND) if(OPENSSL_FOUND)
include_directories(${OPENSSL_INCLUDE_DIRS}) 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()
endif() endif()

View file

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

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

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

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

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

View file

@ -214,6 +214,7 @@ set(cockatrice_SOURCES
src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp
src/interface/widgets/deck_editor/deck_list_style_proxy.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_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/deck_editor/printing_disabled_info_widget.cpp
src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/background_sources.cpp
src/interface/widgets/general/display/background_plate_widget.cpp src/interface/widgets/general/display/background_plate_widget.cpp
@ -271,6 +272,7 @@ set(cockatrice_SOURCES
src/interface/widgets/server/user/user_context_menu.cpp src/interface/widgets/server/user/user_context_menu.cpp
src/interface/widgets/server/user/user_info_box.cpp src/interface/widgets/server/user/user_info_box.cpp
src/interface/widgets/server/user/user_info_connection.cpp src/interface/widgets/server/user/user_info_connection.cpp
src/interface/widgets/server/user/user_list_dialog.cpp
src/interface/widgets/server/user/user_list_manager.cpp src/interface/widgets/server/user/user_list_manager.cpp
src/interface/widgets/server/user/user_list_painter.cpp src/interface/widgets/server/user/user_list_painter.cpp
src/interface/widgets/server/user/user_list_panel_widget.cpp src/interface/widgets/server/user/user_list_panel_widget.cpp
@ -515,6 +517,8 @@ qt6_add_executable(
MANUAL_FINALIZATION MANUAL_FINALIZATION
) )
target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
qt6_add_shaders( qt6_add_shaders(
cockatrice cockatrice
"onboarding_shaders" "onboarding_shaders"

View file

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

View file

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

View file

@ -44,11 +44,16 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
GameScene::~GameScene() GameScene::~GameScene()
{ {
// Sever all incoming connections (animated item destroy-tracking) before the // Sever all destroyed->removeAnimatedItem connections before the members below
// members below are destroyed: the base QGraphicsScene destructor destroys the // are destroyed: the base QGraphicsScene destructor destroys the remaining items,
// remaining items, and their destroyed() signals must not reach slots that // and their destroyed() signals must not reach slots that reference members that
// reference members that no longer exist. // no longer exist. The connection handle overload is used because the string-based
QObject::disconnect(nullptr, nullptr, this, nullptr); // 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; delete animationTimer;
animationTimer = nullptr; animationTimer = nullptr;
@ -777,8 +782,15 @@ void GameScene::registerAnimationItem(IAnimatedItem *item)
if (!object) { if (!object) {
return; return;
} }
if (!animatedItems.contains(object)) { // Guard against duplicate connections using the connection map, not
connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem); // 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); animatedItems.insert(object, item);
if (animationTimer && !animationTimer->isActive()) { if (animationTimer && !animationTimer->isActive()) {
@ -797,6 +809,7 @@ void GameScene::unregisterAnimationItem(IAnimatedItem *item)
void GameScene::removeAnimatedItem(QObject *item) void GameScene::removeAnimatedItem(QObject *item)
{ {
animatedItems.remove(item); animatedItems.remove(item);
animationItemConnections.remove(item);
if (animationTimer && animatedItems.isEmpty()) { if (animationTimer && animatedItems.isEmpty()) {
animationTimer->stop(); animationTimer->stop();
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -83,7 +83,7 @@ SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) cons
return {cardCount, boundingRect().height(), cardHeight, offset, minOffset}; 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(); const auto &cards = getLogic()->getCards();
if (cards.isEmpty()) { if (cards.isEmpty()) {
@ -94,7 +94,8 @@ int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const
if (effectiveOffset <= 0.0) { if (effectiveOffset <= 0.0) {
return 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() void SelectZone::restoreStaleEscapedCards()

View file

@ -104,8 +104,12 @@ protected:
/** /**
* @brief Computes the card index at a given y-coordinate within the zone's vertical layout. * @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. * 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. * @brief Positions cards vertically with alternating left/right x-offsets.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -90,6 +90,13 @@ void CardDatabaseView::decrementCard(const QString &zoneName)
emit cardDecremented(currentCardName(), zoneName); emit cardDecremented(currentCardName(), zoneName);
} }
void CardDatabaseView::setZoneMenuProvider(const std::function<QList<QPair<QString, QStringList>>()> &provider,
const std::function<QString()> &newZoneHandler)
{
zoneMenuProvider = provider;
this->newZoneHandler = newZoneHandler;
}
void CardDatabaseView::updateCard(const QModelIndex &current, const QModelIndex & /*previous*/) void CardDatabaseView::updateCard(const QModelIndex &current, const QModelIndex & /*previous*/)
{ {
if (!current.isValid()) { if (!current.isValid()) {
@ -142,6 +149,50 @@ void CardDatabaseView::openCustomMenu(QPoint point)
[this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); }); [this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); });
connect(selectPrinting, &QAction::triggered, this, &CardDatabaseView::selectPrintingClicked); 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)) { if (canBeCommander(*card)) {
QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)")); QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)"));
connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); }); connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); });

View file

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

View file

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

View file

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

View file

@ -19,6 +19,7 @@
#include <QComboBox> #include <QComboBox>
#include <QDockWidget> #include <QDockWidget>
#include <QLabel> #include <QLabel>
#include <QMenu>
#include <QPushButton> #include <QPushButton>
#include <QTextEdit> #include <QTextEdit>
#include <QTreeView> #include <QTreeView>
@ -102,6 +103,11 @@ private:
[[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const; [[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const;
void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement); void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement);
void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString &currentBoardName);
void addChangeBoardMenu(QMenu *menu, const QString &zoneName);
QString createNewCustomZone(const QString &initialBoardName = {});
void addNewZoneAction(QMenu *menu, const QString &initialBoardName = {});
private slots: private slots:
void decklistCustomMenu(QPoint point); void decklistCustomMenu(QPoint point);
void updateCard(QModelIndex, const QModelIndex &current); void updateCard(QModelIndex, const QModelIndex &current);

View file

@ -2,6 +2,7 @@
#include <libcockatrice/card/database/card_database_manager.h> #include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list_history_manager.h> #include <libcockatrice/deck_list/deck_list_history_manager.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
DeckStateManager::DeckStateManager(QObject *parent) DeckStateManager::DeckStateManager(QObject *parent)
: QObject(parent), deckList(QSharedPointer<DeckList>(new DeckList)), : QObject(parent), deckList(QSharedPointer<DeckList>(new DeckList)),
@ -307,6 +308,170 @@ bool DeckStateManager::decrementCountAtIndex(const QModelIndex &idx)
return offsetCountAtIndex(idx, -1); 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) bool DeckStateManager::offsetCountAtIndex(const QModelIndex &idx, int offset)
{ {
if (!idx.isValid()) { if (!idx.isValid()) {
@ -367,6 +532,25 @@ void DeckStateManager::requestHistorySave(const QString &reason)
historyManager->save(deckList->createMemento(reason)); historyManager->save(deckList->createMemento(reason));
} }
bool DeckStateManager::modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation)
{
DeckListMemento memento = deckList->createMemento(reason);
bool success = operation(deckList->getTree());
if (success) {
historyManager->save(memento);
deckListModel->rebuildTree();
deckList->refreshDeckHash();
emit deckListModel->deckHashChanged();
// removeCustomZone can drop whole card sets the model never notified
// about (rebuildTree emits no cardNodesChanged), so tell the consumers.
emit deckListModel->cardNodesChanged();
doCardModified();
}
return success;
}
/** /**
* @brief Handles updating state and emitting signals whenever the cards are modified * @brief Handles updating state and emitting signals whenever the cards are modified
*/ */

View file

@ -5,6 +5,7 @@
#include "deck_list_model.h" #include "deck_list_model.h"
#include <QSharedPointer> #include <QSharedPointer>
#include <functional>
#include <libcockatrice/deck_list/deck_list.h> #include <libcockatrice/deck_list/deck_list.h>
class DeckListHistoryManager; class DeckListHistoryManager;
@ -236,6 +237,68 @@ public:
*/ */
bool decrementCountAtIndex(const QModelIndex &idx); 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. * 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. * @param steps Number of steps to undo.
@ -257,6 +320,7 @@ public slots:
private: private:
bool offsetCountAtIndex(const QModelIndex &idx, int offset); bool offsetCountAtIndex(const QModelIndex &idx, int offset);
bool modifyTree(const QString &reason, const std::function<bool(DecklistNodeTree *)> &operation);
void doCardModified(); void doCardModified();
void doMetadataModified(); void doMetadataModified();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,335 +1,21 @@
#include "user_list_widget.h" #include "user_list_widget.h"
#include "../../../../client/settings/cache_settings.h" #include "../../../../client/settings/cache_settings.h"
#include "../../../card_picture_loader/card_picture_loader.h"
#include "../../cards/art_crop_attribution.h"
#include "../../interface/pixel_map_generator.h" #include "../../interface/pixel_map_generator.h"
#include "../../interface/theme_manager.h" #include "../../interface/theme_manager.h"
#include "../../interface/widgets/tabs/tab_account.h"
#include "../../interface/widgets/tabs/tab_supervisor.h" #include "../../interface/widgets/tabs/tab_supervisor.h"
#include "../game_selector.h"
#include "user_context_menu.h" #include "user_context_menu.h"
#include "user_list_painter.h" #include "user_list_painter.h"
#include <QApplication> #include <QEvent>
#include <QCheckBox>
#include <QCursor>
#include <QFont>
#include <QFontMetrics>
#include <QFrame>
#include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
#include <QInputDialog>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QMenu> #include <QMenu>
#include <QMessageBox>
#include <QMouseEvent> #include <QMouseEvent>
#include <QPainter> #include <QPainter>
#include <QPainterPath> #include <QScrollBar>
#include <QPlainTextEdit> #include <QTimer>
#include <QPushButton> #include <QVBoxLayout>
#include <QRadioButton>
#include <QScreen>
#include <QSignalBlocker>
#include <QSpinBox>
#include <QWidget>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pb/response_get_user_info.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/appearance_settings.h> #include <libcockatrice/settings/appearance_settings.h>
#include <libcockatrice/utility/string_limits.h>
BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent)
{
setAttribute(Qt::WA_DeleteOnClose);
nameBanCheckBox = new QCheckBox(tr("ban &user name"));
nameBanCheckBox->setChecked(true);
nameBanEdit = new QLineEdit(QString::fromStdString(info.name()));
nameBanEdit->setMaxLength(MAX_NAME_LENGTH);
ipBanCheckBox = new QCheckBox(tr("ban &IP address"));
ipBanCheckBox->setChecked(true);
ipBanEdit = new QLineEdit(QString::fromStdString(info.address()));
ipBanEdit->setMaxLength(MAX_NAME_LENGTH);
idBanCheckBox = new QCheckBox(tr("ban client I&D"));
idBanCheckBox->setChecked(true);
idBanEdit = new QLineEdit(QString::fromStdString(info.clientid()));
idBanEdit->setMaxLength(MAX_NAME_LENGTH);
if (QString::fromStdString(info.clientid()).isEmpty()) {
idBanCheckBox->setChecked(false);
}
QGridLayout *banTypeGrid = new QGridLayout;
banTypeGrid->addWidget(nameBanCheckBox, 0, 0);
banTypeGrid->addWidget(nameBanEdit, 0, 1);
banTypeGrid->addWidget(ipBanCheckBox, 1, 0);
banTypeGrid->addWidget(ipBanEdit, 1, 1);
banTypeGrid->addWidget(idBanCheckBox, 2, 0);
banTypeGrid->addWidget(idBanEdit, 2, 1);
QGroupBox *banTypeGroupBox = new QGroupBox(tr("Ban type"));
banTypeGroupBox->setLayout(banTypeGrid);
permanentRadio = new QRadioButton(tr("&permanent ban"));
temporaryRadio = new QRadioButton(tr("&temporary ban"));
temporaryRadio->setChecked(true);
connect(temporaryRadio, &QRadioButton::toggled, this, &BanDialog::enableTemporaryEdits);
daysLabel = new QLabel(tr("&Days:"));
daysEdit = new QSpinBox;
daysEdit->setMinimum(0);
daysEdit->setValue(0);
daysEdit->setMaximum(10000);
daysLabel->setBuddy(daysEdit);
hoursLabel = new QLabel(tr("&Hours:"));
hoursEdit = new QSpinBox;
hoursEdit->setMinimum(0);
hoursEdit->setValue(0);
hoursEdit->setMaximum(24);
hoursLabel->setBuddy(hoursEdit);
minutesLabel = new QLabel(tr("&Minutes:"));
minutesEdit = new QSpinBox;
minutesEdit->setMinimum(0);
minutesEdit->setValue(5);
minutesEdit->setMaximum(60);
minutesLabel->setBuddy(minutesEdit);
QGridLayout *durationLayout = new QGridLayout;
durationLayout->addWidget(permanentRadio, 0, 0, 1, 6);
durationLayout->addWidget(temporaryRadio, 1, 0, 1, 6);
durationLayout->addWidget(daysLabel, 2, 0);
durationLayout->addWidget(daysEdit, 2, 1);
durationLayout->addWidget(hoursLabel, 2, 2);
durationLayout->addWidget(hoursEdit, 2, 3);
durationLayout->addWidget(minutesLabel, 2, 4);
durationLayout->addWidget(minutesEdit, 2, 5);
QGroupBox *durationGroupBox = new QGroupBox(tr("Duration of the ban"));
durationGroupBox->setLayout(durationLayout);
QLabel *reasonLabel = new QLabel(tr("Please enter the reason for the ban.\nThis is only saved for moderators and "
"cannot be seen by the banned person."));
reasonEdit = new QPlainTextEdit;
QLabel *visibleReasonLabel =
new QLabel(tr("Please enter the reason for the ban that will be visible to the banned person."));
visibleReasonEdit = new QPlainTextEdit;
deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms"));
QPushButton *okButton = new QPushButton(tr("&OK"));
okButton->setAutoDefault(true);
connect(okButton, &QPushButton::clicked, this, &BanDialog::okClicked);
QPushButton *cancelButton = new QPushButton(tr("&Cancel"));
connect(cancelButton, &QPushButton::clicked, this, &BanDialog::reject);
QHBoxLayout *buttonLayout = new QHBoxLayout;
buttonLayout->addStretch();
buttonLayout->addWidget(okButton);
buttonLayout->addWidget(cancelButton);
QVBoxLayout *vbox = new QVBoxLayout;
vbox->addWidget(banTypeGroupBox);
vbox->addWidget(durationGroupBox);
vbox->addWidget(reasonLabel);
vbox->addWidget(reasonEdit);
vbox->addWidget(visibleReasonLabel);
vbox->addWidget(visibleReasonEdit);
vbox->addWidget(deleteMessages);
vbox->addLayout(buttonLayout);
setLayout(vbox);
setWindowTitle(tr("Ban user from server"));
}
WarningDialog::WarningDialog(const QString userName, const QString clientID, QWidget *parent) : QDialog(parent)
{
setAttribute(Qt::WA_DeleteOnClose);
descriptionLabel = new QLabel(tr("Which warning would you like to send?"));
nameWarning = new QLineEdit(userName);
nameWarning->setMaxLength(MAX_NAME_LENGTH);
warnClientID = new QLineEdit(clientID);
warnClientID->setMaxLength(MAX_NAME_LENGTH);
warningOption = new QComboBox();
warningOption->addItem("", "");
deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms"));
QPushButton *okButton = new QPushButton(tr("&OK"));
okButton->setAutoDefault(true);
connect(okButton, &QPushButton::clicked, this, &WarningDialog::okClicked);
QPushButton *cancelButton = new QPushButton(tr("&Cancel"));
connect(cancelButton, &QPushButton::clicked, this, &WarningDialog::reject);
QHBoxLayout *buttonLayout = new QHBoxLayout;
buttonLayout->addStretch();
buttonLayout->addWidget(okButton);
buttonLayout->addWidget(cancelButton);
QVBoxLayout *vbox = new QVBoxLayout;
vbox->addWidget(descriptionLabel);
vbox->addWidget(nameWarning);
vbox->addWidget(warningOption);
vbox->addWidget(deleteMessages);
vbox->addLayout(buttonLayout);
setLayout(vbox);
setWindowTitle(tr("Warn user for misconduct"));
}
void WarningDialog::okClicked()
{
if (nameWarning->text().simplified().isEmpty()) {
QMessageBox::critical(this, tr("Error"),
tr("User name to send a warning to can not be blank, please specify a user to warn."));
return;
}
if (warningOption->currentData().toString().simplified().isEmpty()) {
QMessageBox::critical(this, tr("Error"),
tr("Warning to use can not be blank, please select a valid warning to send."));
return;
}
accept();
}
QString WarningDialog::getName() const
{
return nameWarning->text().simplified();
}
QString WarningDialog::getWarnID() const
{
return warnClientID->text().simplified();
}
QString WarningDialog::getReason() const
{
return warningOption->currentData().toString().simplified();
}
int WarningDialog::getDeleteMessages() const
{
return deleteMessages->isChecked() ? -1 : 0;
}
void WarningDialog::addWarningOption(const QString warning, int startingIl)
{
if (startingIl > 1) {
warningOption->addItem(tr("%1 (IL %2)").arg(warning).arg(startingIl), warning);
} else {
warningOption->addItem(warning, warning);
}
}
void BanDialog::okClicked()
{
if (!nameBanCheckBox->isChecked() && !ipBanCheckBox->isChecked() && !idBanCheckBox->isChecked()) {
QMessageBox::critical(this, tr("Error"),
tr("You have to select a name-based, IP-based, clientId based, or some combination of "
"the three to place a ban."));
return;
}
if (nameBanCheckBox->isChecked()) {
if (nameBanEdit->text().simplified() == "") {
QMessageBox::critical(this, tr("Error"),
tr("You must have a value in the name ban when selecting the name ban checkbox."));
return;
}
}
if (ipBanCheckBox->isChecked()) {
if (ipBanEdit->text().simplified() == "") {
QMessageBox::critical(this, tr("Error"),
tr("You must have a value in the ip ban when selecting the ip ban checkbox."));
return;
}
}
if (idBanCheckBox->isChecked()) {
if (idBanEdit->text().simplified() == "") {
QMessageBox::critical(
this, tr("Error"),
tr("You must have a value in the clientid ban when selecting the clientid ban checkbox."));
return;
}
}
accept();
}
void BanDialog::enableTemporaryEdits(bool enabled)
{
daysLabel->setEnabled(enabled);
daysEdit->setEnabled(enabled);
hoursLabel->setEnabled(enabled);
hoursEdit->setEnabled(enabled);
minutesLabel->setEnabled(enabled);
minutesEdit->setEnabled(enabled);
}
QString BanDialog::getBanId() const
{
return idBanCheckBox->isChecked() ? idBanEdit->text() : QString();
}
QString BanDialog::getBanName() const
{
return nameBanCheckBox->isChecked() ? nameBanEdit->text() : QString();
}
QString BanDialog::getBanIP() const
{
return ipBanCheckBox->isChecked() ? ipBanEdit->text() : QString();
}
int BanDialog::getMinutes() const
{
return permanentRadio->isChecked() ? 0
: (daysEdit->value() * 24 * 60 + hoursEdit->value() * 60 + minutesEdit->value());
}
QString BanDialog::getReason() const
{
return reasonEdit->toPlainText();
}
QString BanDialog::getVisibleReason() const
{
return visibleReasonEdit->toPlainText();
}
int BanDialog::getDeleteMessages() const
{
return deleteMessages->isChecked() ? -1 : 0;
}
AdminNotesDialog::AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent)
: QDialog(_parent), userName(_userName)
{
setAttribute(Qt::WA_DeleteOnClose);
auto *updateButton = new QPushButton(tr("Update Notes"));
updateButton->setEnabled(false);
connect(updateButton, &QPushButton::clicked, this, &AdminNotesDialog::accept);
notes = new QPlainTextEdit(_notes);
notes->setMinimumWidth(500);
connect(notes, &QPlainTextEdit::textChanged, this, [=]() { updateButton->setEnabled(true); });
auto *vbox = new QVBoxLayout;
vbox->addWidget(notes);
vbox->addWidget(updateButton);
setLayout(vbox);
setWindowTitle(tr("Admin Notes for %1").arg(_userName));
}
QString AdminNotesDialog::getNotes() const
{
return notes->toPlainText();
}
namespace UserListRoles namespace UserListRoles
{ {
@ -659,6 +345,11 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
&cardArtProvider->cache(), &cardArtParamsMap, &cardArtProvider->cache(), &cardArtParamsMap,
window()); // parented to main window so it floats above siblings window()); // parented to main window so it floats above siblings
// The invite availability is scoped to the room this list belongs to,
// and gated on the room's buddy-only setting for the hovered user.
userInfoPopup->setGameInviteAvailable(
[this](const QString &userName) { return userContextMenu->hasGameInviteLink(userName); });
userInfoPopup->hide(); userInfoPopup->hide();
userInfoPopup->setWindowOpacity(0.0); userInfoPopup->setWindowOpacity(0.0);
userInfoPopup->installEventFilter(this); userInfoPopup->installEventFilter(this);
@ -976,6 +667,8 @@ void UserListWidget::connectPopupSignals()
// Wire all action signals to UserContextMenu::exec*() // Wire all action signals to UserContextMenu::exec*()
connect(userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat); connect(userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat);
connect(userInfoPopup, &UserInfoPopup::inviteRequested, this,
[this](const QString &userName) { userContextMenu->execInvite(userName); });
connect(userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails); connect(userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails);
connect(userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames); connect(userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames);
connect(userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy); connect(userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy);

View file

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

View file

@ -154,6 +154,48 @@ AppearanceSettingsPage::AppearanceSettingsPage()
homeTabGroupBox = new QGroupBox; homeTabGroupBox = new QGroupBox;
homeTabGroupBox->setLayout(homeTabGrid); homeTabGroupBox->setLayout(homeTabGrid);
// Playmat settings
playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll);
playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly);
playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone);
int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility());
if (visIdx >= 0) {
playmatVisibilityCombo.setCurrentIndex(visIdx);
}
connect(&playmatVisibilityCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt());
});
playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo);
// Playmat mode: Override / Fallback / Deck-only
playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck);
playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback);
playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly);
int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode());
if (modeIdx >= 0) {
playmatModeCombo.setCurrentIndex(modeIdx);
}
connect(&playmatModeCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt());
});
playmatModeLabel.setBuddy(&playmatModeCombo);
// User-level playmat settings: fallback collection.
connect(&playmatDefaultEditButton, &QPushButton::clicked, this,
&AppearanceSettingsPage::openPlaymatCollectionDialog);
auto *playmatGrid = new QGridLayout;
playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1);
playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1);
playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1);
playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1);
playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1);
playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1);
playmatGroupBox = new QGroupBox;
playmatGroupBox->setLayout(playmatGrid);
// Styling settings
styleUserListCheckBox.setChecked(settings.appearance().getStyleUserList()); styleUserListCheckBox.setChecked(settings.appearance().getStyleUserList());
connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(),
&AppearanceSettings::setStyleUserList); &AppearanceSettings::setStyleUserList);
@ -259,7 +301,6 @@ AppearanceSettingsPage::AppearanceSettingsPage()
cardLayoutGroupBox->setLayout(cardLayoutGrid); cardLayoutGroupBox->setLayout(cardLayoutGrid);
// Card counter colors // Card counter colors
auto *cardCounterColorsLayout = new QGridLayout; auto *cardCounterColorsLayout = new QGridLayout;
cardCounterColorsLayout->setColumnStretch(1, 1); cardCounterColorsLayout->setColumnStretch(1, 1);
cardCounterColorsLayout->setColumnStretch(3, 1); cardCounterColorsLayout->setColumnStretch(3, 1);
@ -339,47 +380,6 @@ AppearanceSettingsPage::AppearanceSettingsPage()
tableGroupBox = new QGroupBox; tableGroupBox = new QGroupBox;
tableGroupBox->setLayout(tableGrid); tableGroupBox->setLayout(tableGrid);
// Playmat settings
playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll);
playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly);
playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone);
int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility());
if (visIdx >= 0) {
playmatVisibilityCombo.setCurrentIndex(visIdx);
}
connect(&playmatVisibilityCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt());
});
playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo);
// Playmat mode: Override / Fallback / Deck-only
playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck);
playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback);
playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly);
int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode());
if (modeIdx >= 0) {
playmatModeCombo.setCurrentIndex(modeIdx);
}
connect(&playmatModeCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt());
});
playmatModeLabel.setBuddy(&playmatModeCombo);
// User-level playmat settings: fallback collection.
connect(&playmatDefaultEditButton, &QPushButton::clicked, this,
&AppearanceSettingsPage::openPlaymatCollectionDialog);
auto *playmatGrid = new QGridLayout;
playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1);
playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1);
playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1);
playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1);
playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1);
playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1);
playmatGroupBox = new QGroupBox;
playmatGroupBox->setLayout(playmatGrid);
// putting it all together // putting it all together
auto *mainLayout = new QVBoxLayout; auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(themeGroupBox); mainLayout->addWidget(themeGroupBox);
@ -512,6 +512,12 @@ void AppearanceSettingsPage::retranslateUi()
homeTabButtonColorSourceBox.setToolTip( homeTabButtonColorSourceBox.setToolTip(
tr("Automatic: extract from background if present, otherwise use theme default")); 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")); stylingGroupBox->setTitle(tr("Styling settings"));
styleUserListCheckBox.setText(tr("Style user list")); styleUserListCheckBox.setText(tr("Style user list"));
@ -554,9 +560,4 @@ void AppearanceSettingsPage::retranslateUi()
tableGroupBox->setTitle(tr("Table grid layout")); tableGroupBox->setTitle(tr("Table grid layout"));
invertVerticalCoordinateCheckBox.setText(tr("Invert vertical coordinate")); invertVerticalCoordinateCheckBox.setText(tr("Invert vertical coordinate"));
minPlayersForMultiColumnLayoutLabel.setText(tr("Minimum player count for multi-column layout:")); 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..."));
} }

View file

@ -44,46 +44,55 @@ private:
QLabel homeTabButtonColorSourceLabel; QLabel homeTabButtonColorSourceLabel;
QComboBox homeTabButtonColorSourceBox; 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; QLabel playmatVisibilityLabel;
QComboBox playmatVisibilityCombo; QComboBox playmatVisibilityCombo;
QLabel playmatModeLabel; QLabel playmatModeLabel;
QComboBox playmatModeCombo; QComboBox playmatModeCombo;
QLabel playmatDefaultLabel; QLabel playmatDefaultLabel;
QPushButton playmatDefaultEditButton; 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<QLabel *> cardCounterNames;
QCheckBox horizontalHandCheckBox;
QCheckBox leftJustifiedHandCheckBox;
QCheckBox invertVerticalCoordinateCheckBox;
QLabel minPlayersForMultiColumnLayoutLabel;
QSpinBox minPlayersForMultiColumnLayoutEdit;
QGroupBox *themeGroupBox; QGroupBox *themeGroupBox;
QGroupBox *homeTabGroupBox; QGroupBox *homeTabGroupBox;
QGroupBox *playmatGroupBox;
QGroupBox *stylingGroupBox; QGroupBox *stylingGroupBox;
QGroupBox *menuGroupBox; QGroupBox *menuGroupBox;
QGroupBox *printingsGroupBox; QGroupBox *printingsGroupBox;
QGroupBox *cardsGroupBox; QGroupBox *cardsGroupBox;
QGroupBox *cardLayoutGroupBox; QGroupBox *cardLayoutGroupBox;
QGroupBox *handGroupBox;
QGroupBox *playmatGroupBox;
QGroupBox *tableGroupBox;
QGroupBox *cardCountersGroupBox; QGroupBox *cardCountersGroupBox;
QList<QLabel *> cardCounterNames; QGroupBox *handGroupBox;
QSpinBox minPlayersForMultiColumnLayoutEdit; QGroupBox *tableGroupBox;
QSpinBox maxFontSizeForCardsEdit;
public: public:
AppearanceSettingsPage(); AppearanceSettingsPage();

View file

@ -425,29 +425,28 @@ void GeneralSettingsPage::updateStartupServerControlsVisibility()
void GeneralSettingsPage::retranslateUi() void GeneralSettingsPage::retranslateUi()
{ {
const auto &settings = SettingsCache::instance();
languageGroupBox->setTitle(tr("Language settings")); languageGroupBox->setTitle(tr("Language settings"));
languageLabel.setText(tr("Language:")); 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( advertiseTranslationPageLabel.setText(
QString("<a href='%1'>%2</a>").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations"))); QString("<a href='%1'>%2</a>").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations")));
deckPathLabel.setText(tr("Decks directory:"));
filtersPathLabel.setText(tr("Filters directory:")); versionGroupBox->setTitle(tr("Version settings"));
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:"));
updateReleaseChannelLabel.setText(tr("Update channel")); updateReleaseChannelLabel.setText(tr("Update channel"));
startupUpdateCheckCheckBox.setText(tr("Check for client updates on startup")); 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")); startupCardUpdateCheckBehaviorLabel.setText(tr("Check for card database updates on startup"));
startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexNone, tr("Don't check")); startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexNone, tr("Don't check"));
startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexPrompt, startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexPrompt,
@ -456,8 +455,13 @@ void GeneralSettingsPage::retranslateUi()
tr("Always update in the background")); tr("Always update in the background"));
cardUpdateCheckIntervalLabel.setText(tr("Check for card database updates every")); cardUpdateCheckIntervalLabel.setText(tr("Check for card database updates every"));
cardUpdateCheckIntervalSpinBox.setSuffix(tr(" days")); 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")); showTipsOnStartup.setText(tr("Show tips on startup"));
startupTabLabel.setText(tr("Startup tab:")); startupTabLabel.setText(tr("Startup tab:"));
startupTabSelector.setItemText(StartupTab::StartupTabHome, tr("Home")); startupTabSelector.setItemText(StartupTab::StartupTabHome, tr("Home"));
@ -473,21 +477,18 @@ void GeneralSettingsPage::retranslateUi()
startupServerLabel.setText(tr("Server:")); startupServerLabel.setText(tr("Server:"));
startupRoomLabel.setText(tr("Room:")); startupRoomLabel.setText(tr("Room:"));
startupRoomNameEdit->setPlaceholderText(tr("Room name")); startupRoomNameEdit->setPlaceholderText(tr("Room name"));
resetAllPathsButton->setText(tr("Reset all paths"));
const auto &settings = SettingsCache::instance(); if (settings.getIsPortableBuild()) {
pathsGroupBox->setTitle(tr("Paths (editing disabled in portable mode)"));
QDate lastCheckDate = settings.updates().getLastCardUpdateCheck(); } else {
int daysAgo = lastCheckDate.daysTo(QDate::currentDate()); pathsGroupBox->setTitle(tr("Paths"));
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()));
} }
updateReleaseChannelBox.setCurrentIndex(oldIndex); 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"));
}

View file

@ -42,6 +42,37 @@ private:
QGroupBox *startupGroupBox; QGroupBox *startupGroupBox;
QGroupBox *pathsGroupBox; 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 *deckPathEdit;
QLineEdit *filtersPathEdit; QLineEdit *filtersPathEdit;
QLineEdit *replaysPathEdit; QLineEdit *replaysPathEdit;
@ -51,33 +82,6 @@ private:
QLineEdit *tokenDatabasePathEdit; QLineEdit *tokenDatabasePathEdit;
QPushButton *resetAllPathsButton; QPushButton *resetAllPathsButton;
QLabel *allPathsResetLabel; 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 #endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H

View file

@ -20,26 +20,7 @@ enum visualDeckStoragePromptForConversionIndex
UserInterfaceSettingsPage::UserInterfaceSettingsPage() UserInterfaceSettingsPage::UserInterfaceSettingsPage()
{ {
// general settings and notification settings // general settings
notificationsEnabledCheckBox.setChecked(SettingsCache::instance().userInterface().getNotificationsEnabled());
connect(&notificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setNotificationsEnabled);
connect(&notificationsEnabledCheckBox, &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);
doubleClickToPlayCheckBox.setChecked(SettingsCache::instance().userInterface().getDoubleClickToPlay()); doubleClickToPlayCheckBox.setChecked(SettingsCache::instance().userInterface().getDoubleClickToPlay());
connect(&doubleClickToPlayCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), connect(&doubleClickToPlayCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setDoubleClickToPlay); &InterfaceSettings::setDoubleClickToPlay);
@ -103,6 +84,26 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
generalGroupBox = new QGroupBox; generalGroupBox = new QGroupBox;
generalGroupBox->setLayout(generalGrid); generalGroupBox->setLayout(generalGrid);
// notification settings
notificationsEnabledCheckBox.setChecked(SettingsCache::instance().userInterface().getNotificationsEnabled());
connect(&notificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setNotificationsEnabled);
connect(&notificationsEnabledCheckBox, &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; auto *notificationsGrid = new QGridLayout;
notificationsGrid->addWidget(&notificationsEnabledCheckBox, 0, 0); notificationsGrid->addWidget(&notificationsEnabledCheckBox, 0, 0);
notificationsGrid->addWidget(&specNotificationsEnabledCheckBox, 1, 0); notificationsGrid->addWidget(&specNotificationsEnabledCheckBox, 1, 0);
@ -355,6 +356,7 @@ void UserInterfaceSettingsPage::retranslateUi()
notificationsEnabledCheckBox.setText(tr("Enable notifications in taskbar")); notificationsEnabledCheckBox.setText(tr("Enable notifications in taskbar"));
specNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar for game events while you are spectating")); 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")); buddyConnectNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar when users in your buddy list connect"));
animationGroupBox->setTitle(tr("Animation settings")); animationGroupBox->setTitle(tr("Animation settings"));
enableAllAnimationsButton.setText(tr("&Enable all animations")); enableAllAnimationsButton.setText(tr("&Enable all animations"));
disableAllAnimationsButton.setText(tr("&Disable all animations")); disableAllAnimationsButton.setText(tr("&Disable all animations"));
@ -362,6 +364,7 @@ void UserInterfaceSettingsPage::retranslateUi()
arrowDrawAnimationCheckBox.setText(tr("&Arrow draw animation")); arrowDrawAnimationCheckBox.setText(tr("&Arrow draw animation"));
lifeCounterAnimationsCheckBox.setText(tr("Life counter flash")); lifeCounterAnimationsCheckBox.setText(tr("Life counter flash"));
battlefieldFlashCheckBox.setText(tr("Battlefield flash on damage")); battlefieldFlashCheckBox.setText(tr("Battlefield flash on damage"));
deckEditorGroupBox->setTitle(tr("Deck editor/storage settings")); deckEditorGroupBox->setTitle(tr("Deck editor/storage settings"));
openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default")); openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default"));
visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby")); visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby"));
@ -397,8 +400,8 @@ void UserInterfaceSettingsPage::retranslateUi()
0, CommanderBracketNames::CommanderSpellbookBracketNames); 0, CommanderBracketNames::CommanderSpellbookBracketNames);
commanderSpellbookIntegrationBracketNamingSelector.setItemText( commanderSpellbookIntegrationBracketNamingSelector.setItemText(
1, CommanderBracketNames::OfficialCommanderBracketNames); 1, CommanderBracketNames::OfficialCommanderBracketNames);
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setToolTip(CommanderBracketNames::Explainer); commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setToolTip(CommanderBracketNames::Explainer);
replayGroupBox->setTitle(tr("Replay settings")); replayGroupBox->setTitle(tr("Replay settings"));
rewindBufferingMsLabel.setText(tr("Buffer time for backwards skip via shortcut:")); rewindBufferingMsLabel.setText(tr("Buffer time for backwards skip via shortcut:"));
rewindBufferingMsBox.setSuffix(" ms"); rewindBufferingMsBox.setSuffix(" ms");

View file

@ -23,9 +23,6 @@ private slots:
void updateCommanderSpellbookUiState(); void updateCommanderSpellbookUiState();
private: private:
QCheckBox notificationsEnabledCheckBox;
QCheckBox specNotificationsEnabledCheckBox;
QCheckBox buddyConnectNotificationsEnabledCheckBox;
QCheckBox doubleClickToPlayCheckBox; QCheckBox doubleClickToPlayCheckBox;
QCheckBox clickPlaysAllSelectedCheckBox; QCheckBox clickPlaysAllSelectedCheckBox;
QCheckBox playToStackCheckBox; QCheckBox playToStackCheckBox;
@ -37,12 +34,18 @@ private:
QCheckBox showTotalSelectionCountCheckBox; QCheckBox showTotalSelectionCountCheckBox;
QCheckBox useTearOffMenusCheckBox; QCheckBox useTearOffMenusCheckBox;
QCheckBox keepGameChatFocusCheckBox; QCheckBox keepGameChatFocusCheckBox;
QCheckBox notificationsEnabledCheckBox;
QCheckBox specNotificationsEnabledCheckBox;
QCheckBox buddyConnectNotificationsEnabledCheckBox;
QPushButton enableAllAnimationsButton; QPushButton enableAllAnimationsButton;
QPushButton disableAllAnimationsButton; QPushButton disableAllAnimationsButton;
QCheckBox tapAnimationCheckBox; QCheckBox tapAnimationCheckBox;
QCheckBox arrowDrawAnimationCheckBox; QCheckBox arrowDrawAnimationCheckBox;
QCheckBox lifeCounterAnimationsCheckBox; QCheckBox lifeCounterAnimationsCheckBox;
QCheckBox battlefieldFlashCheckBox; QCheckBox battlefieldFlashCheckBox;
QCheckBox openDeckInNewTabCheckBox; QCheckBox openDeckInNewTabCheckBox;
QLabel visualDeckStoragePromptForConversionLabel; QLabel visualDeckStoragePromptForConversionLabel;
QComboBox visualDeckStoragePromptForConversionSelector; QComboBox visualDeckStoragePromptForConversionSelector;
@ -57,8 +60,10 @@ private:
QLabel commanderSpellbookIntegrationUseOfficialBracketNamesLabel; QLabel commanderSpellbookIntegrationUseOfficialBracketNamesLabel;
QToolButton commanderSpellbookIntegrationUseOfficialBracketNamesExplainer; QToolButton commanderSpellbookIntegrationUseOfficialBracketNamesExplainer;
QComboBox commanderSpellbookIntegrationBracketNamingSelector; QComboBox commanderSpellbookIntegrationBracketNamingSelector;
QLabel rewindBufferingMsLabel; QLabel rewindBufferingMsLabel;
QSpinBox rewindBufferingMsBox; QSpinBox rewindBufferingMsBox;
QGroupBox *generalGroupBox; QGroupBox *generalGroupBox;
QGroupBox *notificationsGroupBox; QGroupBox *notificationsGroupBox;
QGroupBox *animationGroupBox; QGroupBox *animationGroupBox;

View file

@ -116,9 +116,10 @@ void CloseButton::paintEvent(QPaintEvent * /*event*/)
} }
TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent) TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent)
: QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabVisualDeckStorage(nullptr), : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr),
tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr), tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr),
tabLog(nullptr), tabReport(nullptr), tabModeration(nullptr), isLocalGame(false) tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr),
tabModeration(nullptr), isLocalGame(false)
{ {
setElideMode(Qt::ElideRight); setElideMode(Qt::ElideRight);
setMovable(true); setMovable(true);
@ -245,6 +246,7 @@ void TabSupervisor::retranslateUi()
aTabLog->setText(tr("Logs")); aTabLog->setText(tr("Logs"));
aTabReport->setText(tr("Report Queue")); aTabReport->setText(tr("Report Queue"));
aTabModeration->setText(tr("Moderation")); aTabModeration->setText(tr("Moderation"));
aTabCardArtRules->setText(tr("Card Art Rules"));
// tabs // tabs
QList<Tab *> tabs; QList<Tab *> tabs;
@ -256,6 +258,7 @@ void TabSupervisor::retranslateUi()
tabs.append(tabLog); tabs.append(tabLog);
tabs.append(tabReport); tabs.append(tabReport);
tabs.append(tabModeration); tabs.append(tabModeration);
tabs.append(tabCardArtRules);
QMapIterator<int, TabRoom *> roomIterator(roomTabs); QMapIterator<int, TabRoom *> roomIterator(roomTabs);
while (roomIterator.hasNext()) { while (roomIterator.hasNext()) {
tabs.append(roomIterator.next().value()); tabs.append(roomIterator.next().value());
@ -520,7 +523,9 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo)
if (SettingsCache::instance().tabs().getTabModerationOpen()) { if (SettingsCache::instance().tabs().getTabModerationOpen()) {
openTabModeration(); openTabModeration();
} }
openTabCardArtRules(); if (SettingsCache::instance().tabs().getTabCardArtRulesOpen()) {
openTabCardArtRules();
}
} }
retranslateUi(); retranslateUi();
@ -582,6 +587,9 @@ void TabSupervisor::stop()
if (tabModeration) { if (tabModeration) {
tabModeration->close(); tabModeration->close();
} }
if (tabCardArtRules) {
tabCardArtRules->close();
}
} }
QList<Tab *> tabsToDelete; QList<Tab *> tabsToDelete;
@ -775,6 +783,7 @@ void TabSupervisor::openTabAdmin()
void TabSupervisor::actTabCardArtRules(bool checked) void TabSupervisor::actTabCardArtRules(bool checked)
{ {
SettingsCache::instance().tabs().setTabCardArtRulesOpen(checked);
if (checked && !tabCardArtRules) { if (checked && !tabCardArtRules) {
openTabCardArtRules(); openTabCardArtRules();
setCurrentWidget(tabCardArtRules); setCurrentWidget(tabCardArtRules);
@ -1091,7 +1100,8 @@ QList<GameInviteOption> TabSupervisor::getGameInviteLinksForRoom(int roomId) con
// The inviter may be in several games of the same room (hosting one and // The inviter may be in several games of the same room (hosting one and
// spectating another, for example). Return every game so the caller can // spectating another, for example). Return every game so the caller can
// let the user choose which one to invite to. // let the user choose which one to invite to.
for (TabGame *tab : gameTabs) { for (auto it = gameTabs.cbegin(); it != gameTabs.cend(); ++it) {
TabGame *tab = it.value();
GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo(); GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo();
if (metaInfo->proto().room_id() != roomId) { if (metaInfo->proto().room_id() != roomId) {
continue; continue;

View file

@ -4,6 +4,7 @@
#include "../../../../client/settings/shortcuts_settings.h" #include "../../../../client/settings/shortcuts_settings.h"
#include "../../cards/card_info_display_widget.h" #include "../../cards/card_info_display_widget.h"
#include "../../deck_editor/deck_state_manager.h" #include "../../deck_editor/deck_state_manager.h"
#include "../../deck_editor/deck_zone_dialog.h"
#include "../../filters/filter_builder.h" #include "../../filters/filter_builder.h"
#include "../../interface/pixel_map_generator.h" #include "../../interface/pixel_map_generator.h"
#include "../../interface/widgets/cards/card_info_frame_widget.h" #include "../../interface/widgets/cards/card_info_frame_widget.h"
@ -84,6 +85,7 @@ void TabDeckEditorVisual::createCentralFrame()
connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this, connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this,
&TabDeckEditorVisual::showPrintingSelector); &TabDeckEditorVisual::showPrintingSelector);
connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo); connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo);
tabContainer->visualDatabaseDisplay->setNewZoneCreator([this] { return createNewZone(); });
centralFrame->addWidget(tabContainer); centralFrame->addWidget(tabContainer);
setCentralWidget(centralWidget); setCentralWidget(centralWidget);
@ -269,6 +271,19 @@ bool TabDeckEditorVisual::actSaveDeckAs()
return result; 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. */ /** @brief Refreshes keyboard shortcuts for this tab from settings. */
void TabDeckEditorVisual::refreshShortcuts() void TabDeckEditorVisual::refreshShortcuts()
{ {

View file

@ -165,6 +165,12 @@ public slots:
*/ */
bool actSaveDeckAs() override; 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: private:
/** /**
* @brief Sets the deck for this tab and selects the sub-tab to open on * @brief Sets the deck for this tab and selects the sub-tab to open on

View file

@ -21,6 +21,7 @@
#include <libcockatrice/card/card_info_comparator.h> #include <libcockatrice/card/card_info_comparator.h>
#include <libcockatrice/card/database/card_database.h> #include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h> #include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <libcockatrice/settings/cards_display_settings.h> #include <libcockatrice/settings/cards_display_settings.h>
#include <utility> #include <utility>
@ -89,6 +90,19 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
databaseView->setItemDelegate(nullptr); databaseView->setItemDelegate(nullptr);
databaseView->setVisible(false); 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<QPair<QString, QStringList>> {
QList<QPair<QString, QStringList>> result;
for (const QString &boardName : InnerDecklistNode::boardZoneNames()) {
result.append({boardName, deckListModel->getCustomZoneNames(boardName)});
}
return result;
},
[this] { return newZoneCreator ? newZoneCreator() : QString(); });
}
searchEdit->setTreeView(databaseView); searchEdit->setTreeView(databaseView);
searchEdit->installEventFilter(databaseView->getKeySignals()); searchEdit->installEventFilter(databaseView->getKeySignals());
@ -195,6 +209,11 @@ void VisualDatabaseDisplayWidget::showEvent(QShowEvent *event)
initializeFilters(); initializeFilters();
} }
void VisualDatabaseDisplayWidget::setNewZoneCreator(const std::function<QString()> &creator)
{
newZoneCreator = creator;
}
void VisualDatabaseDisplayWidget::retranslateUi() void VisualDatabaseDisplayWidget::retranslateUi()
{ {
databaseLoadIndicator->setText(tr("Loading database ...")); databaseLoadIndicator->setText(tr("Loading database ..."));

View file

@ -22,6 +22,7 @@
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWheelEvent> #include <QWheelEvent>
#include <QWidget> #include <QWidget>
#include <functional>
#include <libcockatrice/models/database/card_database_model.h> #include <libcockatrice/models/database/card_database_model.h>
#include <libcockatrice/models/deck_list/deck_list_model.h> #include <libcockatrice/models/deck_list/deck_list_model.h>
#include <qscrollarea.h> #include <qscrollarea.h>
@ -46,6 +47,12 @@ public:
void sortCardList(const QStringList &properties, Qt::SortOrder order) const; void sortCardList(const QStringList &properties, Qt::SortOrder order) const;
void setDeckList(const DeckList &new_deck_list_model); 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<QString()> &creator);
CardDatabaseDisplayModel *getDatabaseDisplayModel() CardDatabaseDisplayModel *getDatabaseDisplayModel()
{ {
return databaseDisplayModel; return databaseDisplayModel;
@ -106,6 +113,7 @@ private:
VisualDatabaseDisplayFilterToolbarWidget *filterContainer; VisualDatabaseDisplayFilterToolbarWidget *filterContainer;
CardDatabaseDisplayModel *databaseDisplayModel; CardDatabaseDisplayModel *databaseDisplayModel;
CardDatabaseView *databaseView; CardDatabaseView *databaseView;
std::function<QString()> newZoneCreator;
QList<ExactCard> *cards; QList<ExactCard> *cards;
QVBoxLayout *mainLayout; QVBoxLayout *mainLayout;
QScrollArea *scrollArea; QScrollArea *scrollArea;

View file

@ -10,8 +10,6 @@
#include "../visual_deck_storage_widget.h" #include "../visual_deck_storage_widget.h"
#include "deck_preview_deck_tags_display_widget.h" #include "deck_preview_deck_tags_display_widget.h"
#include <QDir>
#include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QInputDialog> #include <QInputDialog>
#include <QLabel> #include <QLabel>
@ -499,21 +497,6 @@ void DeckPreviewWidget::actDeleteFile()
// The folder widget removes this preview once the row is gone. // The folder widget removes this preview once the row is gone.
} }
static bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath)
{
QFileInfo fileInfo(filePath);
QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod");
if (QFile::exists(newFileName)) {
QMessageBox::StandardButton reply =
QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"),
QObject::tr("A .cod version of this deck already exists. Overwrite it?"),
QMessageBox::Yes | QMessageBox::No);
return reply == QMessageBox::Yes;
}
return true; // Safe to proceed
}
/** /**
* Checks if the deck's file format supports tags. * Checks if the deck's file format supports tags.
* If not, then prompt the user for file conversion. * If not, then prompt the user for file conversion.
@ -521,45 +504,8 @@ static bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath)
*/ */
bool DeckPreviewWidget::promptFileConversionIfRequired() bool DeckPreviewWidget::promptFileConversionIfRequired()
{ {
if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) { return DialogConvertDeckToCodFormat::promptIfRequired(this, filePath, [this] {
return true;
}
// Retrieve saved preference if the prompt is disabled
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) {
if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) {
return false;
}
if (!confirmOverwriteIfExists(this, filePath)) {
return false;
}
model->convertToCockatriceFormat(row()); model->convertToCockatriceFormat(row());
return true; return true;
} });
// Show the dialog to the user
DialogConvertDeckToCodFormat conversionDialog(this);
if (conversionDialog.exec() != QDialog::Accepted) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(
!conversionDialog.dontAskAgain());
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false);
return false;
}
// Try to convert file
if (!confirmOverwriteIfExists(this, filePath)) {
return false;
}
model->convertToCockatriceFormat(row());
if (conversionDialog.dontAskAgain()) {
SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false);
SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true);
}
return true;
} }

View file

@ -125,9 +125,7 @@ void VisualDeckStorageFolderDisplayWidget::continueDeckPass()
} }
const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool(); const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool();
if (matches == deckPreviewWidget->isHidden()) { deckPreviewWidget->setVisible(matches);
deckPreviewWidget->setVisible(matches);
}
if (matches) { if (matches) {
++visibleDeckCount; ++visibleDeckCount;
} }

View file

@ -53,6 +53,7 @@
#include <libcockatrice/settings/interface_settings.h> #include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/network_settings.h> #include <libcockatrice/settings/network_settings.h>
#include <libcockatrice/settings/personal_settings.h> #include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/utility/cryptoutil.h>
QTranslator *translator, *qtTranslator; QTranslator *translator, *qtTranslator;
RNG_Abstract *rng; 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; themeManager = new ThemeManager;
soundEngine = new SoundEngine; soundEngine = new SoundEngine;

View file

@ -18,11 +18,11 @@ include=("cockatrice/src" \
libcockatrice_* \ libcockatrice_* \
"oracle/src" \ "oracle/src" \
"servatrice/src" \ "servatrice/src" \
"cmake/pch" \
"tests") "tests")
exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \
"libcockatrice_utility/libcockatrice/utility/peglib.h" \ "libcockatrice_utility/libcockatrice/utility/peglib.h" \
"oracle/src/lzma/" \ "oracle/src/lzma/" \
"oracle/src/qt-json/" \
"oracle/src/zip/" \ "oracle/src/zip/" \
"servatrice/src/smtp/") "servatrice/src/smtp/")
exts=("cpp" "h" "proto") exts=("cpp" "h" "proto")

View file

@ -5,6 +5,8 @@
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QSet> #include <QSet>
static constexpr int MAX_DECK_SIZE = 1e5;
DecklistNodeTree::DecklistNodeTree() : root(new InnerDecklistNode()) DecklistNodeTree::DecklistNodeTree() : root(new InnerDecklistNode())
{ {
} }
@ -113,7 +115,7 @@ void DecklistNodeTree::readZoneElement(QXmlStreamReader *xml)
{ {
QString zoneName = xml->attributes().value("name").toString(); QString zoneName = xml->attributes().value("name").toString();
InnerDecklistNode *newZone = getZoneObjFromName(zoneName); InnerDecklistNode *newZone = getZoneObjFromName(zoneName);
newZone->readElement(xml); totalCards += newZone->readElement(xml, MAX_DECK_SIZE - totalCards);
} }
DecklistCardNode *DecklistNodeTree::addCard(const QString &cardName, DecklistCardNode *DecklistNodeTree::addCard(const QString &cardName,
@ -125,6 +127,8 @@ DecklistCardNode *DecklistNodeTree::addCard(const QString &cardName,
const QString &cardProviderId, const QString &cardProviderId,
const bool formatLegal) const bool formatLegal)
{ {
amount = qMin(amount, MAX_DECK_SIZE - totalCards);
totalCards += amount;
auto *zoneNode = getZoneObjFromName(zoneName); auto *zoneNode = getZoneObjFromName(zoneName);
auto *node = new DecklistCardNode(cardName, amount, zoneNode, position, cardSetName, cardSetCollectorNumber, auto *node = new DecklistCardNode(cardName, amount, zoneNode, position, cardSetName, cardSetCollectorNumber,
cardProviderId, formatLegal); cardProviderId, formatLegal);

View file

@ -10,6 +10,7 @@
class DecklistNodeTree class DecklistNodeTree
{ {
InnerDecklistNode *root; ///< Root of the deck tree (zones + cards). InnerDecklistNode *root; ///< Root of the deck tree (zones + cards).
int totalCards = 0;
public: public:
/** @brief Constructs an empty DecklistNodeTree. */ /** @brief Constructs an empty DecklistNodeTree. */
@ -114,6 +115,25 @@ public:
*/ */
QList<const InnerDecklistNode *> getCustomZones(const QString &boardZoneName) const; QList<const InnerDecklistNode *> 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. * @brief Applies a function to every card in the deck tree. This can modify the cards.
* *
@ -127,8 +147,6 @@ private:
InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const; InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const;
InnerDecklistNode *findBoardZone(const QString &boardZoneName) const; InnerDecklistNode *findBoardZone(const QString &boardZoneName) const;
InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName); InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName);
InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const;
bool hasZoneName(const QString &zoneName) const;
}; };
#endif // COCKATRICE_DECKLIST_NODE_TREE_H #endif // COCKATRICE_DECKLIST_NODE_TREE_H

View file

@ -34,15 +34,15 @@ bool AbstractDecklistCardNode::compareName(AbstractDecklistNode *other) const
} }
} }
bool AbstractDecklistCardNode::readElement(QXmlStreamReader *xml) int AbstractDecklistCardNode::readElement(QXmlStreamReader *xml, int /* limit */)
{ {
while (!xml->atEnd()) { while (!xml->atEnd()) {
xml->readNext(); xml->readNext();
if (xml->isEndElement() && xml->name().toString() == "card") { if (xml->isEndElement() && xml->name().toString() == "card") {
return false; return 0;
} }
} }
return true; return 0;
} }
void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml) void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml)
@ -60,4 +60,4 @@ void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml)
if (!getCardProviderId().isEmpty()) { if (!getCardProviderId().isEmpty()) {
xml->writeAttribute("uuid", getCardProviderId()); xml->writeAttribute("uuid", getCardProviderId());
} }
} }

View file

@ -141,7 +141,7 @@ public:
* *
* This supports loading deck files from Cockatrices XML format. * This supports loading deck files from Cockatrices XML format.
*/ */
bool readElement(QXmlStreamReader *xml) override; int readElement(QXmlStreamReader *xml, int limit) override;
/** /**
* @brief Serialize this nodes properties to XML. * @brief Serialize this nodes properties to XML.

View file

@ -183,7 +183,7 @@ public:
* Cockatrice deck XML format. * Cockatrice deck XML format.
* @{ * @{
*/ */
virtual bool readElement(QXmlStreamReader *xml) = 0; virtual int readElement(QXmlStreamReader *xml, int limit) = 0;
virtual void writeElement(QXmlStreamWriter *xml) = 0; virtual void writeElement(QXmlStreamWriter *xml) = 0;
/// @} /// @}
}; };

View file

@ -43,6 +43,13 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method)
} }
} }
const QList<QString> &InnerDecklistNode::boardZoneNames()
{
static const QList<QString> names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE),
QString(DECK_ZONE_MAYBEBOARD)};
return names;
}
QString InnerDecklistNode::getVisibleName() const QString InnerDecklistNode::getVisibleName() const
{ {
return visibleNameFromName(name); return visibleNameFromName(name);
@ -87,6 +94,9 @@ AbstractDecklistNode *InnerDecklistNode::findCardChildByNameProviderIdAndNumber(
int InnerDecklistNode::height() const int InnerDecklistNode::height() const
{ {
if (isEmpty()) {
return 1;
}
return at(0)->height() + 1; return at(0)->height() + 1;
} }
@ -141,27 +151,31 @@ bool InnerDecklistNode::compareName(AbstractDecklistNode *other) const
} }
} }
bool InnerDecklistNode::readElement(QXmlStreamReader *xml) int InnerDecklistNode::readElement(QXmlStreamReader *xml, int limit)
{ {
int totalCards = 0;
while (!xml->atEnd()) { while (!xml->atEnd()) {
xml->readNext(); xml->readNext();
const QString childName = xml->name().toString(); const QString childName = xml->name().toString();
if (xml->isStartElement()) { if (xml->isStartElement()) {
if (childName == "zone") { if (childName == "zone") {
auto *newZone = new InnerDecklistNode(xml->attributes().value("name").toString(), this); auto *newZone = new InnerDecklistNode(xml->attributes().value("name").toString(), this);
newZone->readElement(xml); totalCards += newZone->readElement(xml, limit - totalCards);
} else if (childName == "card") { } else if (childName == "card") {
auto *newCard = new DecklistCardNode( int amount = xml->attributes().value("number").toString().toInt();
xml->attributes().value("name").toString(), xml->attributes().value("number").toString().toInt(), amount = qMin(amount, limit - totalCards);
this, -1, xml->attributes().value("setShortName").toString(), auto *newCard = new DecklistCardNode(xml->attributes().value("name").toString(), amount, this, -1,
xml->attributes().value("collectorNumber").toString(), xml->attributes().value("uuid").toString()); xml->attributes().value("setShortName").toString(),
newCard->readElement(xml); xml->attributes().value("collectorNumber").toString(),
xml->attributes().value("uuid").toString());
totalCards += amount;
totalCards += newCard->readElement(xml, limit - totalCards);
} }
} else if (xml->isEndElement() && (childName == "zone")) { } else if (xml->isEndElement() && (childName == "zone")) {
return false; return totalCards;
} }
} }
return true; return totalCards;
} }
void InnerDecklistNode::writeElement(QXmlStreamWriter *xml) void InnerDecklistNode::writeElement(QXmlStreamWriter *xml)
@ -201,4 +215,4 @@ QVector<QPair<int, int>> InnerDecklistNode::sort(Qt::SortOrder order)
} }
return result; return result;
} }

View file

@ -18,6 +18,9 @@
#include "abstract_deck_list_node.h" #include "abstract_deck_list_node.h"
#include <QList>
#include <QString>
/** @brief Constant for the "main" deck zone name. */ /** @brief Constant for the "main" deck zone name. */
#define DECK_ZONE_MAIN "main" #define DECK_ZONE_MAIN "main"
/** @brief Constant for the "sideboard" zone name. */ /** @brief Constant for the "sideboard" zone name. */
@ -118,6 +121,13 @@ public:
*/ */
static QString visibleNameFromName(const QString &_name); static QString visibleNameFromName(const QString &_name);
/**
* @brief The standard board zone names, in display order.
*
* @return main, side and maybeboard.
*/
static const QList<QString> &boardZoneNames();
/** /**
* @brief Get this nodes display-friendly name. * @brief Get this nodes display-friendly name.
* @return Human-readable name (zone/group name). * @return Human-readable name (zone/group name).
@ -216,9 +226,10 @@ public:
/** /**
* @brief Deserialize this node and its children from XML. * @brief Deserialize this node and its children from XML.
* @param xml Reader positioned at this element. * @param xml Reader positioned at this element.
* @return true if parsing succeeded. * @param limit The maximum amount of cards to read
* @return the amount of cards found
*/ */
bool readElement(QXmlStreamReader *xml) override; int readElement(QXmlStreamReader *xml, int limit) override;
/** /**
* @brief Serialize this node and its children to XML. * @brief Serialize this node and its children to XML.

View file

@ -5,6 +5,7 @@
#include <QRegularExpression> #include <QRegularExpression>
#include <QString> #include <QString>
#include <functional> #include <functional>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/utility/peglib.h> #include <libcockatrice/utility/peglib.h>
static peg::parser search(R"( 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 QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery
NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart
SetQuery <- ('e'/'set') [:] FlexStringValue SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue)
OracleQuery <- 'o' [:] MatcherString OracleQuery <- 'o' [:] MatcherString
@ -64,6 +65,8 @@ RegexMatcherString <- ('\\/' / !'/' .)+
FlexStringValue <- CompactStringSet / String / [(] StringList [)] FlexStringValue <- CompactStringSet / String / [(] StringList [)]
CompactStringSet <- StringListString ([,+] StringListString)+ CompactStringSet <- StringListString ([,+] StringListString)+
SetExpression <- NumericOperator ws? String
NumericExpression <- NumericOperator ws? NumericValue NumericExpression <- NumericOperator ws? NumericValue
NumericOperator <- [=:] / <[><!][=]?> NumericOperator <- [=:] / <[><!][=]?>
NumericValue <- [0-9]+ NumericValue <- [0-9]+
@ -101,12 +104,25 @@ static void setupParserRules()
return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; return [=](const CardData &x) -> bool { return matcher(x->getCardType()); };
}; };
search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter { search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter {
auto matcher = std::any_cast<StringMatcher>(sv[0]); if (sv.choice() == 1) {
return [=](const CardData &x) -> bool { auto matcher = std::any_cast<StringMatcher>(sv[0]);
QList<QString> sets = x->getSets().keys(); return [=](const CardData &x) -> bool {
QList<QString> sets = x->getSets().keys();
auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; auto matchesSet = [&matcher](const QString &set) { return matcher(set); };
return std::any_of(sets.begin(), sets.end(), matchesSet); return std::any_of(sets.begin(), sets.end(), matchesSet);
};
}
auto matcher = std::any_cast<NumberMatcher>(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 { search["Rarity"] = [](const peg::SemanticValues &sv) -> QString {
@ -247,40 +263,54 @@ static void setupParserRules()
return QString::fromStdString(std::string(sv.sv())); return QString::fromStdString(std::string(sv.sv()));
}; };
search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { search["NumericOperator"] = [](const peg::SemanticValues &sv) -> NumberComparer {
const auto arg = std::any_cast<int>(sv[1]); const auto op = QString::fromStdString(std::string(sv.sv()));
const auto op = std::any_cast<QString>(sv[0]);
if (op == ">") { if (op == ">") {
return [=](const int s) { return s > arg; }; return [=](const int s, const int arg) { return s > arg; };
} }
if (op == ">=") { if (op == ">=") {
return [=](const int s) { return s >= arg; }; return [=](const int s, const int arg) { return s >= arg; };
} }
if (op == "<") { if (op == "<") {
return [=](const int s) { return s < arg; }; return [=](const int s, const int arg) { return s < arg; };
} }
if (op == "<=") { if (op == "<=") {
return [=](const int s) { return s <= arg; }; return [=](const int s, const int arg) { return s <= arg; };
} }
if (op == "=") { if (op == "=") {
return [=](const int s) { return s == arg; }; return [=](const int s, const int arg) { return s == arg; };
} }
if (op == ":") { if (op == ":") {
return [=](const int s) { return s == arg; }; return [=](const int s, const int arg) { return s == arg; };
} }
if (op == "!=") { 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 { search["NumericValue"] = [](const peg::SemanticValues &sv) -> int {
return QString::fromStdString(std::string(sv.sv())).toInt(); return QString::fromStdString(std::string(sv.sv())).toInt();
}; };
search["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString { search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
return QString::fromStdString(std::string(sv.sv())); const auto comparer = std::any_cast<NumberComparer>(sv[0]);
const auto arg = std::any_cast<int>(sv[1]);
return [=](int s) { return comparer(s, arg); };
};
search["SetExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher {
const auto comparer = std::any_cast<NumberComparer>(sv[0]);
const auto setCode = std::any_cast<QString>(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 { search["NormalMatcher"] = [](const peg::SemanticValues &sv) -> StringMatcher {

View file

@ -22,6 +22,7 @@ typedef CardInfoPtr CardData;
typedef std::function<bool(const CardData &)> Filter; typedef std::function<bool(const CardData &)> Filter;
typedef std::function<bool(const QString &)> StringMatcher; typedef std::function<bool(const QString &)> StringMatcher;
typedef std::function<bool(int)> NumberMatcher; typedef std::function<bool(int)> NumberMatcher;
typedef std::function<bool(int, int)> NumberComparer;
namespace peg namespace peg
{ {

View file

@ -21,6 +21,7 @@ public:
[[nodiscard]] virtual bool getTabLogOpen() const = 0; [[nodiscard]] virtual bool getTabLogOpen() const = 0;
[[nodiscard]] virtual bool getTabReportOpen() const = 0; [[nodiscard]] virtual bool getTabReportOpen() const = 0;
[[nodiscard]] virtual bool getTabModerationOpen() const = 0; [[nodiscard]] virtual bool getTabModerationOpen() const = 0;
[[nodiscard]] virtual bool getTabCardArtRulesOpen() const = 0;
}; };
#endif // COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H #endif // COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H

View file

@ -7,7 +7,8 @@ set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h)
qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) qt6_wrap_cpp(MOC_SOURCES ${HEADERS})
add_library( 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}) target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

View file

@ -66,7 +66,8 @@ void DeckListModel::rebuildTree()
for (int j = 0; j < currentZone->size(); j++) { for (int j = 0; j < currentZone->size(); j++) {
auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j)); auto *currentCard = dynamic_cast<DecklistCardNode *>(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) { if (currentCard == nullptr) {
continue; continue;
} }
@ -82,8 +83,19 @@ void DeckListModel::rebuildTree()
new DecklistModelCardNode(currentCard, groupNode); 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(); endResetModel();
refreshCardFormatLegalities(); refreshCardFormatLegalities();
@ -154,6 +166,9 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
case DeckRoles::IsLegalRole: case DeckRoles::IsLegalRole:
return true; return true;
case DeckRoles::IsCustomZoneRole:
return DeckListModelCustomZones::isCustomZone(group);
default: default:
return {}; return {};
} }
@ -190,6 +205,10 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const
return card->getFormatLegality(); return card->getFormatLegality();
} }
case DeckRoles::IsCustomZoneRole: {
return false;
}
default: { default: {
return {}; return {};
} }
@ -327,6 +346,13 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
return false; 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); beginRemoveRows(parent, row, row + count - 1);
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
AbstractDecklistNode *toDelete = node->takeAt(row); AbstractDecklistNode *toDelete = node->takeAt(row);
@ -337,7 +363,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
} }
endRemoveRows(); 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()); removeRows(parent.row(), 1, parent.parent());
} else { } else {
emitRecursiveUpdates(parent); emitRecursiveUpdates(parent);
@ -351,7 +378,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent)
InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent) InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent)
{ {
auto *newNode = dynamic_cast<InnerDecklistNode *>(parent->findChild(name)); // Group lookups must not resolve a mirrored custom zone that shares the name.
auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name);
if (!newNode) { if (!newNode) {
beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); beginInsertRows(nodeToIndex(parent), parent->size(), parent->size());
newNode = new InnerDecklistNode(name, parent); newNode = new InnerDecklistNode(name, parent);
@ -365,24 +393,44 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName,
const QString &providerId, const QString &providerId,
const QString &cardNumber) const const QString &cardNumber) const
{ {
InnerDecklistNode *zoneNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName));
if (!zoneNode) {
return nullptr;
}
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName); CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName);
if (!info) { if (!info) {
return nullptr; return nullptr;
} }
QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); // 1. Board zone lookup: search the criteria groups, then the custom zones
InnerDecklistNode *groupNode = dynamic_cast<InnerDecklistNode *>(zoneNode->findChild(groupCriteria)); // nested under the board.
if (!groupNode) { if (auto *zoneNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName))) {
return nullptr; QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria);
if (auto *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) {
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
return card;
}
}
for (auto *child : *zoneNode) {
if (!DeckListModelCustomZones::isCustomZone(child)) {
continue;
}
auto *customZone = dynamic_cast<InnerDecklistNode *>(child);
if (!customZone) {
continue;
}
if (auto *card = dynamic_cast<DecklistModelCardNode *>(
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) {
return card;
}
}
} }
return dynamic_cast<DecklistModelCardNode *>( // 2. Custom zone lookup by name (custom zone names are deck-unique).
groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); if (auto *customZone = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) {
return dynamic_cast<DecklistModelCardNode *>(
customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber));
}
return nullptr;
} }
QModelIndex DeckListModel::findCard(const QString &cardName, QModelIndex DeckListModel::findCard(const QString &cardName,
@ -423,29 +471,95 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam
return {}; return {};
} }
InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root);
CardInfoPtr cardInfo = card.getCardPtr(); CardInfoPtr cardInfo = card.getCardPtr();
PrintingInfo printingInfo = card.getPrinting(); PrintingInfo printingInfo = card.getPrinting();
QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); InnerDecklistNode *cardParent = nullptr;
InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode);
const QModelIndex parentIndex = nodeToIndex(groupNode); auto *boardNode = dynamic_cast<InnerDecklistNode *>(root->findChild(zoneName));
auto *cardNode = dynamic_cast<DecklistModelCardNode *>(groupNode->findCardChildByNameProviderIdAndNumber( 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<InnerDecklistNode *>(listRoot->at(i));
if (!boardZone) {
continue;
}
deckCardParent = dynamic_cast<InnerDecklistNode *>(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<InnerDecklistNode *>(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<InnerDecklistNode *>(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<InnerDecklistNode *>(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<DecklistModelCardNode *>(cardParent->findCardChildByNameProviderIdAndNumber(
card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num"))); card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num")));
const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName(); const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName();
bool cardNodeAdded = false; bool cardNodeAdded = false;
if (!cardNode) { if (!cardNode) {
// Determine the correct index // 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")); printingInfo.getProperty("num"), printingInfo.getProperty("uuid"));
beginInsertRows(parentIndex, insertRow, insertRow); beginInsertRows(parentIndex, insertRow, insertRow);
cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow); cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow);
endInsertRows(); endInsertRows();
cardNodeAdded = true; cardNodeAdded = true;
@ -576,21 +690,41 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const
return createIndex(node->getParent()->indexOf(node), 0, node); 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<InnerDecklistNode *>(node->at(i))) {
sortShadowTree(subNode, order);
}
}
}
void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order) void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order)
{ {
// Sort children of node and save the information needed to // Sort children (custom zones always sorted after groups within a board) and
// update the list of persistent indexes. // use the movement mapping to update the list of persistent indices.
QVector<QPair<int, int>> sortResult = node->sort(order); const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order);
QModelIndexList from, to; QModelIndexList from, to;
int columns = columnCount(); int columns = columnCount();
for (int i = sortResult.size() - 1; i >= 0; --i) { for (const auto &move : mapping) {
const int fromRow = sortResult[i].first; const int preSortRow = move.first;
const int toRow = sortResult[i].second; const int finalRow = move.second;
AbstractDecklistNode *temp = node->at(toRow); AbstractDecklistNode *temp = node->at(finalRow);
for (int j = 0; j < columns; ++j) { for (int j = 0; j < columns; ++j) {
from << createIndex(fromRow, j, temp); from << createIndex(preSortRow, j, temp);
to << createIndex(toRow, j, temp); to << createIndex(finalRow, j, temp);
} }
} }
changePersistentIndexList(from, to); changePersistentIndexList(from, to);
@ -704,6 +838,15 @@ QList<QString> DeckListModel::getZones() const
return zones; 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) static int maxAllowedForLegality(const FormatRules &format, const QString &legality)
{ {
for (const AllowedCount &c : format.allowedCounts) { for (const AllowedCount &c : format.allowedCounts) {

View file

@ -1,6 +1,8 @@
#ifndef DECKLISTMODEL_H #ifndef DECKLISTMODEL_H
#define 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/abstract_deck_list_card_node.h>
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h> #include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <QAbstractItemModel> #include <QAbstractItemModel>
@ -30,7 +32,8 @@ enum
{ {
IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */ IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */
DepthRole, /**< Depth level within the deck's grouping hierarchy. */ 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 } // namespace DeckRoles
@ -391,6 +394,14 @@ public:
*/ */
[[nodiscard]] QList<QString> getZones() const; [[nodiscard]] QList<QString> 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: private:
QSharedPointer<DeckList> deckList; /**< Pointer to the decklist providing the underlying data. */ QSharedPointer<DeckList> deckList; /**< Pointer to the decklist providing the underlying data. */
InnerDecklistNode *root; /**< Root node of the model tree. */ InnerDecklistNode *root; /**< Root node of the model tree. */
@ -427,6 +438,7 @@ private:
void emitRecursiveUpdates(const QModelIndex &index); void emitRecursiveUpdates(const QModelIndex &index);
void sortHelper(InnerDecklistNode *node, Qt::SortOrder order); void sortHelper(InnerDecklistNode *node, Qt::SortOrder order);
void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order);
template <typename T> T getNode(const QModelIndex &index) const template <typename T> T getNode(const QModelIndex &index) const
{ {

View file

@ -0,0 +1,152 @@
#include "deck_list_model_custom_zones.h"
#include "deck_list_model.h"
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <QHash>
#include <QVector>
namespace DeckListModelCustomZones
{
bool isCustomZone(const AbstractDecklistNode *node)
{
return dynamic_cast<const DecklistModelSubZoneNode *>(node) != nullptr;
}
namespace
{
/**
* @brief Flattens every card under @p zone into @p shadowZone, preserving order.
*
* Custom zones mirror as a single row level: cards nested in sub-zones of any
* depth are added as direct children of the mirrored zone so no card is left
* without a model row.
*/
void flattenCards(const InnerDecklistNode *zone, InnerDecklistNode *shadowZone)
{
for (int k = 0; k < zone->size(); k++) {
if (auto *zoneCard = dynamic_cast<DecklistCardNode *>(zone->at(k))) {
new DecklistModelCardNode(zoneCard, shadowZone);
} else if (auto *subZone = dynamic_cast<const InnerDecklistNode *>(zone->at(k))) {
flattenCards(subZone, shadowZone);
}
}
}
} // namespace
void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone)
{
for (int j = 0; j < deckBoardZone->size(); j++) {
auto *customZone = dynamic_cast<const InnerDecklistNode *>(deckBoardZone->at(j));
if (!customZone) {
continue;
}
auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), shadowBoardZone);
flattenCards(customZone, shadowZone);
}
}
InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name)
{
for (int i = 0; i < parent->size(); i++) {
AbstractDecklistNode *child = parent->at(i);
if (isCustomZone(child)) {
continue;
}
auto *group = dynamic_cast<InnerDecklistNode *>(child);
if (group && group->getName() == name) {
return group;
}
}
return nullptr;
}
DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName)
{
for (int i = 0; i < root->size(); i++) {
auto *boardZone = dynamic_cast<InnerDecklistNode *>(root->at(i));
if (!boardZone) {
continue;
}
for (int j = 0; j < boardZone->size(); j++) {
auto *customZone = dynamic_cast<DecklistModelSubZoneNode *>(boardZone->at(j));
if (customZone && customZone->getName() == zoneName) {
return customZone;
}
}
}
return nullptr;
}
namespace
{
/**
* @brief Sorts a node's children and returns the (preSortRow, finalRow) mapping.
*/
QList<QPair<int, int>> plainSort(InnerDecklistNode *node, Qt::SortOrder order)
{
const QVector<QPair<int, int>> sortResult = node->sort(order);
QList<QPair<int, int>> mapping;
mapping.reserve(node->size());
for (int i = 0; i < node->size(); ++i) {
mapping.append({sortResult[i].first, i});
}
return mapping;
}
/**
* @brief Sorts a board zone's children, then stably moves custom zones to the end.
*
* @return The (preSortRow, finalRow) mapping covering both the sort and the shift.
*/
QList<QPair<int, int>> boardSort(InnerDecklistNode *node, Qt::SortOrder order)
{
const QVector<QPair<int, int>> sortResult = node->sort(order);
QVector<AbstractDecklistNode *> groups;
QVector<AbstractDecklistNode *> customZones;
QHash<AbstractDecklistNode *, int> preSortRowOf;
groups.reserve(node->size());
customZones.reserve(node->size());
for (int i = 0; i < node->size(); ++i) {
AbstractDecklistNode *child = node->at(i);
preSortRowOf.insert(child, sortResult[i].first);
if (isCustomZone(child)) {
customZones.append(child);
} else {
groups.append(child);
}
}
QVector<AbstractDecklistNode *> ordered = groups + customZones;
for (int i = 0; i < ordered.size(); ++i) {
node->replace(i, ordered[i]);
}
QList<QPair<int, int>> mapping;
mapping.reserve(ordered.size());
for (int i = 0; i < ordered.size(); ++i) {
mapping.append({preSortRowOf.value(ordered[i]), i});
}
return mapping;
}
} // namespace
QList<QPair<int, int>> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order)
{
const bool isBoardZone = (node != root) && (node->getParent() == root);
return isBoardZone ? boardSort(node, order) : plainSort(node, order);
}
} // namespace DeckListModelCustomZones

View file

@ -0,0 +1,98 @@
#ifndef DECK_LIST_MODEL_CUSTOM_ZONES_H
#define DECK_LIST_MODEL_CUSTOM_ZONES_H
#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h>
#include <QList>
#include <QPair>
#include <QtGlobal>
/**
* @class DecklistModelSubZoneNode
* @ingroup DeckModels
* @brief Model node representing a custom zone nested under a board zone.
*
* Custom zones group cards by user-defined names (e.g. "Removal", "Utility")
* inside a board zone. They are mirrored from the underlying deck tree so that
* they can be told apart from criteria group nodes by type.
*/
class DecklistModelSubZoneNode : public InnerDecklistNode
{
public:
using InnerDecklistNode::InnerDecklistNode;
};
/**
* @namespace DeckListModelCustomZones
* @ingroup DeckModels
* @brief Tree-level helpers for the deck list model's custom-zone shadow nodes.
*
* The deck list model keeps a second "shadow" tree of InnerDecklistNode that
* mirrors the canonical deck tree for grouping and sorting. Custom zones add a
* layer of bookkeeping to that shadow tree: they must be mirrored alongside
* criteria groups, always sort after the groups within a board, and be
* resolvable by deck-unique name.
*
* This namespace centralizes every "what is / where is a custom zone" decision
* so the model itself only wires the results into Qt model signals.
*/
namespace DeckListModelCustomZones
{
/**
* @brief Whether the given node is a custom zone (as opposed to a criteria group).
*/
[[nodiscard]] bool isCustomZone(const AbstractDecklistNode *node);
/**
* @brief Finds a criteria-group child of @p parent by name, skipping custom zones.
*
* The shadow tree keeps criteria groups and mirrored custom zones as siblings
* under a board zone, and `InnerDecklistNode::findChild` matches both by name.
* Group lookups must not resolve a custom zone that happens to share the group
* name (e.g. a zone called "Creature"), so this searches only non-custom
* children.
*
* @param parent The shadow node whose children are searched.
* @param name The group name to find.
* @return The matching group node, or nullptr if none exists.
*/
[[nodiscard]] InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name);
/**
* @brief Mirrors the custom zones of a deck board zone into its shadow board node.
*
* Each custom zone becomes a DecklistModelSubZoneNode under @p shadowBoardZone
* with its cards as direct (un-grouped) children.
*
* @param deckBoardZone The board zone in the canonical deck tree.
* @param shadowBoardZone The matching board zone in the model's shadow tree.
*/
void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone);
/**
* @brief Finds a custom zone in the shadow tree by deck-unique name.
* @param root Root of the shadow tree.
* @param zoneName The custom zone name to find.
* @return The matching custom zone node, or nullptr if not found.
*/
[[nodiscard]] DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName);
/**
* @brief Sorts a shadow node's children, keeping a board's custom zones last.
*
* Sorting alone would interleave custom zones with criteria groups by name, but
* custom zones must always stay after the groups within a board, regardless of
* name. This applies the sort and, for board zones, stably moves the custom
* zones to the end.
*
* @param root Root of the shadow tree (used to classify board zones).
* @param node The shadow node whose children are reordered.
* @param order Sort order to apply.
* @return A list of (preSortRow, finalRow) pairs describing how each node moved.
*/
[[nodiscard]] QList<QPair<int, int>>
sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order);
} // namespace DeckListModelCustomZones
#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H

View file

@ -90,7 +90,6 @@ set(PROTO_FILES
event_game_log_notice.proto event_game_log_notice.proto
event_game_say.proto event_game_say.proto
event_game_state_changed.proto event_game_state_changed.proto
event_game_state_changed.proto
event_join.proto event_join.proto
event_join_room.proto event_join_room.proto
event_kicked.proto event_kicked.proto

View file

@ -1,6 +1,5 @@
#include "rng_sfmt.h" #include "rng_sfmt.h"
#include <QDateTime>
#include <algorithm> #include <algorithm>
#include <climits> #include <climits>
#include <stdexcept> #include <stdexcept>
@ -11,10 +10,11 @@
#define UINT64_MAX (~(uint64_t)0) #define UINT64_MAX (~(uint64_t)0)
#endif #endif
RNG_SFMT::RNG_SFMT(QObject *parent) : RNG_Abstract(parent) RNG_SFMT::RNG_SFMT(uint64_t seed, QObject *parent) : RNG_Abstract(parent)
{ {
// initialize the random number generator with a 32bit integer seed (timestamp) // initialize the random number generator with a 64bit seed, e.g. from a CSPRNG
sfmt_init_gen_rand(&sfmt, QDateTime::currentDateTime().toSecsSinceEpoch()); uint32_t seedArray[2] = {static_cast<uint32_t>(seed), static_cast<uint32_t>(seed >> 32)};
sfmt_init_by_array(&sfmt, seedArray, 2);
} }
/** /**

View file

@ -36,7 +36,7 @@ private:
unsigned int cdf(unsigned int min, unsigned int max); unsigned int cdf(unsigned int min, unsigned int max);
public: public:
explicit RNG_SFMT(QObject *parent = nullptr); explicit RNG_SFMT(uint64_t seed, QObject *parent = nullptr);
unsigned int rand(int min, int max) override; unsigned int rand(int min, int max) override;
}; };

View file

@ -106,6 +106,11 @@ bool TabsSettings::getTabModerationOpen() const
return getValue("moderation", QString(), QString(), false).toBool(); return getValue("moderation", QString(), QString(), false).toBool();
} }
bool TabsSettings::getTabCardArtRulesOpen() const
{
return getValue("cardArtRules", QString(), QString(), false).toBool();
}
void TabsSettings::setTabVisualDeckStorageOpen(bool value) void TabsSettings::setTabVisualDeckStorageOpen(bool value)
{ {
setValue(value, "visualDeckStorage"); setValue(value, "visualDeckStorage");
@ -150,3 +155,8 @@ void TabsSettings::setTabModerationOpen(bool value)
{ {
setValue(value, "moderation"); setValue(value, "moderation");
} }
void TabsSettings::setTabCardArtRulesOpen(bool value)
{
setValue(value, "cardArtRules");
}

View file

@ -43,6 +43,7 @@ public:
[[nodiscard]] bool getTabLogOpen() const override; [[nodiscard]] bool getTabLogOpen() const override;
[[nodiscard]] bool getTabReportOpen() const override; [[nodiscard]] bool getTabReportOpen() const override;
[[nodiscard]] bool getTabModerationOpen() const override; [[nodiscard]] bool getTabModerationOpen() const override;
[[nodiscard]] bool getTabCardArtRulesOpen() const override;
void setStartupTabIndex(int value); void setStartupTabIndex(int value);
void setStartupServerHost(const QString &host); void setStartupServerHost(const QString &host);
@ -57,6 +58,7 @@ public:
void setTabLogOpen(bool value); void setTabLogOpen(bool value);
void setTabReportOpen(bool value); void setTabReportOpen(bool value);
void setTabModerationOpen(bool value); void setTabModerationOpen(bool value);
void setTabCardArtRulesOpen(bool value);
signals: signals:
void startupTabIndexChanged(int index); void startupTabIndexChanged(int index);

View file

@ -6,13 +6,15 @@ set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
set(UTILITY_SOURCES set(UTILITY_SOURCES
libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/cryptoutil.cpp libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp
libcockatrice/utility/server_rate_limiter.cpp libcockatrice/utility/warning_categories.cpp libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/server_rate_limiter.cpp
libcockatrice/utility/warning_categories.cpp
) )
set(UTILITY_HEADERS set(UTILITY_HEADERS
libcockatrice/utility/card_ref.h libcockatrice/utility/card_ref.h
libcockatrice/utility/color.h libcockatrice/utility/color.h
libcockatrice/utility/cryptoutil.h
libcockatrice/utility/expression.h libcockatrice/utility/expression.h
libcockatrice/utility/levenshtein.h libcockatrice/utility/levenshtein.h
libcockatrice/utility/macros.h libcockatrice/utility/macros.h
@ -32,7 +34,9 @@ add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS})
target_include_directories(libcockatrice_utility PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(libcockatrice_utility PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng ${QT_CORE_MODULE}) find_package(OpenSSL REQUIRED)
target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng OpenSSL::Crypto ${QT_CORE_MODULE})
set(ORACLE_LIBS) set(ORACLE_LIBS)

View file

@ -0,0 +1,25 @@
#include "cryptoutil.h"
#include <openssl/rand.h>
namespace CryptoUtil
{
QByteArray randomBytes(int count)
{
QByteArray bytes(count, '\0');
if (RAND_bytes(reinterpret_cast<unsigned char *>(bytes.data()), count) != 1) {
// Randomness failure is fatal: never fall back to a predictable source.
qFatal("CryptoUtil::randomBytes: RAND_bytes failed");
}
return bytes;
}
quint64 randomUInt64()
{
quint64 value;
if (RAND_bytes(reinterpret_cast<unsigned char *>(&value), sizeof(value)) != 1) {
qFatal("CryptoUtil::randomUInt64: RAND_bytes failed");
}
return value;
}
} // namespace CryptoUtil

View file

@ -0,0 +1,13 @@
#ifndef CRYPTOUTIL_H
#define CRYPTOUTIL_H
#include <QByteArray>
#include <QtGlobal>
namespace CryptoUtil
{
QByteArray randomBytes(int count);
quint64 randomUInt64();
} // namespace CryptoUtil
#endif

View file

@ -1,7 +1,7 @@
#include "passwordhasher.h" #include "passwordhasher.h"
#include <QCryptographicHash> #include <QCryptographicHash>
#include <libcockatrice/rng/rng_sfmt.h> #include <libcockatrice/utility/cryptoutil.h>
QString PasswordHasher::computeHash(const QString &password, const QString &salt) QString PasswordHasher::computeHash(const QString &password, const QString &salt)
{ {
@ -21,12 +21,28 @@ QString PasswordHasher::generateRandomSalt(const int len)
static const char alphanum[] = "0123456789" static const char alphanum[] = "0123456789"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"; "abcdefghijklmnopqrstuvwxyz";
const int size = sizeof(alphanum) - 1;
// Two bytes per character, corrected for modulo bias via rejection sampling.
const int bucketSize = 65536 / size;
const int limit = bucketSize * size;
QString ret; QString ret;
int size = sizeof(alphanum) - 1; ret.reserve(len);
QByteArray random = CryptoUtil::randomBytes(len * 2);
int bytesUsed = 0;
for (int i = 0; i < len; ++i) { for (int i = 0; i < len; ++i) {
ret.append(alphanum[rng->rand(0, size)]); unsigned int value;
do {
if (bytesUsed >= random.size()) {
random = CryptoUtil::randomBytes(len * 2);
bytesUsed = 0;
}
value = static_cast<unsigned int>(static_cast<unsigned char>(random.at(bytesUsed))) << 8 |
static_cast<unsigned int>(static_cast<unsigned char>(random.at(bytesUsed + 1)));
bytesUsed += 2;
} while (value >= limit);
ret.append(alphanum[value / bucketSize]);
} }
return ret; return ret;
@ -34,5 +50,5 @@ QString PasswordHasher::generateRandomSalt(const int len)
QString PasswordHasher::generateActivationToken() QString PasswordHasher::generateActivationToken()
{ {
return QCryptographicHash::hash(generateRandomSalt().toUtf8(), QCryptographicHash::Md5).toBase64().left(16); return QString(CryptoUtil::randomBytes(16).toBase64().left(16));
} }

View file

@ -23,7 +23,7 @@ set(oracle_SOURCES
src/pages.cpp src/pages.cpp
src/pagetemplates.cpp src/pagetemplates.cpp
src/parsehelpers.cpp src/parsehelpers.cpp
src/qt-json/json.cpp src/raw_json_scanner.cpp
../cockatrice/src/client/settings/cache_settings.cpp ../cockatrice/src/client/settings/cache_settings.cpp
../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/card_counter_settings.cpp
../cockatrice/src/client/settings/shortcuts_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp
@ -112,6 +112,8 @@ qt6_add_executable(
MANUAL_FINALIZATION MANUAL_FINALIZATION
) )
target_precompile_headers(oracle PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
# ------------------------ # ------------------------
# Link libraries # Link libraries
# ------------------------ # ------------------------

View file

@ -3,10 +3,13 @@
#include "libcockatrice/interfaces/noop_card_preference_provider.h" #include "libcockatrice/interfaces/noop_card_preference_provider.h"
#include "libcockatrice/interfaces/noop_card_set_priority_controller.h" #include "libcockatrice/interfaces/noop_card_set_priority_controller.h"
#include "parsehelpers.h" #include "parsehelpers.h"
#include "qt-json/json.h"
#include <QDebug> #include <QDebug>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QRegularExpression> #include <QRegularExpression>
#include <QSet>
#include <algorithm> #include <algorithm>
#include <climits> #include <climits>
#include <libcockatrice/card/database/parser/cockatrice_xml_4.h> #include <libcockatrice/card/database/parser/cockatrice_xml_4.h>
@ -42,31 +45,29 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s
return priority; return priority;
} }
bool OracleImporter::readSetsFromByteArray(const QByteArray &data) bool OracleImporter::readSetsFromByteArray(QByteArray data)
{ {
bool ok; RawJson::ScanError error;
auto setsMap = QtJson::Json::parse(QString(data), ok).toMap().value("data").toMap(); const QList<RawJson::SetRange> ranges = RawJson::scanSetRanges(data, &error);
if (!ok) { if (error.isError()) {
qDebug() << "error: QtJson::Json::parse()"; qDebug() << "error: RawJson::scanSetRanges():" << error.message;
return false; return false;
} }
QList<SetToDownload> newSetList; QList<SetToDownload> newSetList;
newSetList.reserve(ranges.size());
QListIterator it(setsMap.values()); for (const RawJson::SetRange &range : ranges) {
QString shortName = range.code.toUpper();
while (it.hasNext()) { QString longName = range.name;
QVariantMap map = it.next().toMap(); QString setType = range.type;
QString shortName = map.value("code").toString().toUpper(); QDate releaseDate = QDate::fromString(range.releaseDate, Qt::ISODate);
QString longName = map.value("name").toString();
QList<QVariant> setCards = map.value("cards").toList();
QString setType = map.value("type").toString();
QDate releaseDate = map.value("releaseDate").toDate();
CardSet::Priority priority = getSetPriority(setType, shortName); CardSet::Priority priority = getSetPriority(setType, shortName);
// capitalize set type // capitalize set type
if (setType.length() > 0) { if (setType.length() > 0) {
// basic grammar for words that aren't capitalized, like in "From the Vault" // basic grammar for words that aren't capitalized, like in "From the Vault"
const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", "of", "in", "and", "with", "or"}; static const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for",
"of", "in", "and", "with", "or"};
QStringList words = setType.split("_"); QStringList words = setType.split("_");
setType.clear(); setType.clear();
bool first = false; bool first = false;
@ -74,13 +75,15 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data)
if (first && noCapitalize.contains(item)) { if (first && noCapitalize.contains(item)) {
setType += item + QString(" "); setType += item + QString(" ");
} else { } else {
setType += item[0].toUpper() + item.mid(1, -1) + QString(" "); setType += item[0].toUpper() + item.mid(1) + QString(" ");
first = true; first = true;
} }
} }
setType = setType.trimmed(); setType = setType.trimmed();
} }
newSetList.append(SetToDownload(shortName, longName, setCards, priority, setType, releaseDate)); SetToDownload set(shortName, longName, priority, setType, releaseDate);
set.setRawRange(range.dataRange);
newSetList.append(set);
} }
std::sort(newSetList.begin(), newSetList.end()); std::sort(newSetList.begin(), newSetList.end());
@ -89,6 +92,7 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data)
return false; return false;
} }
allSets = newSetList; allSets = newSetList;
rawSetsData = std::move(data);
return true; return true;
} }
@ -122,14 +126,8 @@ static void sortAndReduceColors(QString &colors)
std::sort(colors.begin(), colors.end(), std::sort(colors.begin(), colors.end(),
[](const QChar a, const QChar b) { return colorOrder.value(a, INT_MAX) < colorOrder.value(b, INT_MAX); }); [](const QChar a, const QChar b) { return colorOrder.value(a, INT_MAX) < colorOrder.value(b, INT_MAX); });
// reduce // reduce
QChar lastChar = '\0'; auto last = std::unique(colors.begin(), colors.end());
for (int i = 0; i < colors.size(); ++i) { colors.erase(last, colors.end());
if (colors.at(i) == lastChar) {
colors.remove(i, 1);
} else {
lastChar = colors.at(i);
}
}
} }
CardInfoPtr OracleImporter::addCard(QString name, CardInfoPtr OracleImporter::addCard(QString name,
@ -142,9 +140,12 @@ CardInfoPtr OracleImporter::addCard(QString name,
// Workaround for card name weirdness // Workaround for card name weirdness
name = name.replace("Æ", "AE"); name = name.replace("Æ", "AE");
name = name.replace("", "'"); name = name.replace("", "'");
if (cards.contains(name)) { auto existingIt = cards.constFind(name);
CardInfoPtr card = cards.value(name); if (existingIt != cards.constEnd()) {
CardInfoPtr card = existingIt.value();
card->addToSet(printingInfo.getSet(), printingInfo); card->addToSet(printingInfo.getSet(), printingInfo);
// Only merge legalities when the card has none yet, so multi-format
// printings don't overwrite each other's legality lists.
if (card->getProperties().filter(formatRegex).empty()) { if (card->getProperties().filter(formatRegex).empty()) {
card->combineLegalities(properties); card->combineLegalities(properties);
} }
@ -182,8 +183,9 @@ CardInfoPtr OracleImporter::addCard(QString name,
// DETECT CARD POSITIONING INFO // DETECT CARD POSITIONING INFO
bool landscapeOrientation = properties.value("maintype") == "Battle" || properties.value("layout") == "split" || QString layoutVal = properties.value("layout");
properties.value("layout") == "planar"; bool landscapeOrientation =
properties.value("maintype") == "Battle" || layoutVal == "split" || layoutVal == "planar";
// cards that enter the field tapped // cards that enter the field tapped
bool cipt = parseCipt(name, text) || landscapeOrientation; bool cipt = parseCipt(name, text) || landscapeOrientation;
@ -222,12 +224,15 @@ CardInfoPtr OracleImporter::addCard(QString name,
return newCard; return newCard;
} }
static QString getStringPropertyFromMap(const QVariantMap &card, const QString &propertyName) static QString getJsonString(const QJsonObject &obj, const QString &key)
{ {
return card.contains(propertyName) ? card.value(propertyName).toString() : QString(""); // QVariant coerces numbers and booleans to text, while QJsonValue::toString()
// returns a null string for them — some MTGJSON fields (manaValue,
// convertedManaCost, isOnlineOnly, isRebalanced) carry those types.
return obj.value(key).toVariant().toString();
} }
int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QList<QVariant> &cardsList) int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QJsonArray &cardsList)
{ {
// mtgjson name => xml name // mtgjson name => xml name
static const QMap<QString, QString> cardProperties{ static const QMap<QString, QString> cardProperties{
@ -248,7 +253,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QList
static const QString ptSeparator = "/"; static const QString ptSeparator = "/";
static constexpr bool isToken = false; static constexpr bool isToken = false;
static const QList<QString> setsWithCardsWithSameNameButDifferentText = {"UST"}; static const QSet<QString> setsWithCardsWithSameNameButDifferentText = {"UST"};
int numCards = 0; int numCards = 0;
@ -256,16 +261,16 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QList
QMap<QString, QPair<QList<SplitCardPart>, QString>> splitCards; QMap<QString, QPair<QList<SplitCardPart>, QString>> splitCards;
// Keeps track of all names encountered so far // Keeps track of all names encountered so far
QList<QString> allNameProps; QSet<QString> allNameProps;
for (const QVariant &cardVar : cardsList) { for (const QJsonValue &cardVal : cardsList) {
QVariantMap card = cardVar.toMap(); QJsonObject card = cardVal.toObject();
/* Currently used layouts are: /* Currently used layouts are:
* augment, double_faced_token, flip, host, leveler, meld, normal, planar, * augment, double_faced_token, flip, host, leveler, meld, normal, planar,
* saga, scheme, split, token, transform, vanguard * saga, scheme, split, token, transform, vanguard
*/ */
QString layout = getStringPropertyFromMap(card, "layout"); QString layout = getJsonString(card, "layout");
// don't import tokens from the json file // don't import tokens from the json file
if (layout == "token") { if (layout == "token") {
@ -273,9 +278,9 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QList
} }
// normal cards handling // normal cards handling
QString name = getStringPropertyFromMap(card, "name"); QString name = getJsonString(card, "name");
QString text = getStringPropertyFromMap(card, "text"); QString text = getJsonString(card, "text");
QString faceName = getStringPropertyFromMap(card, "faceName"); QString faceName = getJsonString(card, "faceName");
if (faceName.isEmpty()) { if (faceName.isEmpty()) {
faceName = name; faceName = name;
} }
@ -283,39 +288,34 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QList
// card properties // card properties
QHash<QString, QString> properties; QHash<QString, QString> properties;
for (auto i = cardProperties.cbegin(), end = cardProperties.cend(); i != end; ++i) { for (auto i = cardProperties.cbegin(), end = cardProperties.cend(); i != end; ++i) {
QString mtgjsonProperty = i.key(); QString propertyValue = getJsonString(card, i.key());
QString xmlPropertyName = i.value();
QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty);
if (!propertyValue.isEmpty()) { if (!propertyValue.isEmpty()) {
properties.insert(xmlPropertyName, propertyValue); properties.insert(i.value(), propertyValue);
} }
} }
// per-set properties // per-set properties
QHash<QString, QString> printingProps; QHash<QString, QString> printingProps;
for (auto i = setInfoProperties.cbegin(), end = setInfoProperties.cend(); i != end; ++i) { for (auto i = setInfoProperties.cbegin(), end = setInfoProperties.cend(); i != end; ++i) {
QString mtgjsonProperty = i.key(); QString propertyValue = getJsonString(card, i.key());
QString xmlPropertyName = i.value();
QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty);
if (!propertyValue.isEmpty()) { if (!propertyValue.isEmpty()) {
printingProps.insert(xmlPropertyName, propertyValue); printingProps.insert(i.value(), propertyValue);
} }
} }
// handle flavorNames specially due to double-faced cards // handle flavorNames specially due to double-faced cards
QString faceFlavorName = getStringPropertyFromMap(card, "faceFlavorName"); QString faceFlavorName = getJsonString(card, "faceFlavorName");
QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getStringPropertyFromMap(card, "flavorName"); QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getJsonString(card, "flavorName");
if (!flavorName.isEmpty()) { if (!flavorName.isEmpty()) {
printingProps.insert("flavorName", flavorName); printingProps.insert("flavorName", flavorName);
} }
// Identifiers // Identifiers
QJsonObject identifiers = card.value("identifiers").toObject();
for (auto i = identifierProperties.cbegin(), end = identifierProperties.cend(); i != end; ++i) { for (auto i = identifierProperties.cbegin(), end = identifierProperties.cend(); i != end; ++i) {
QString mtgjsonProperty = i.key(); QString propertyValue = getJsonString(identifiers, i.key());
QString xmlPropertyName = i.value();
QString propertyValue = getStringPropertyFromMap(card.value("identifiers").toMap(), mtgjsonProperty);
if (!propertyValue.isEmpty()) { if (!propertyValue.isEmpty()) {
printingProps.insert(xmlPropertyName, propertyValue); printingProps.insert(i.value(), propertyValue);
} }
} }
@ -331,21 +331,26 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QList
allNameProps.contains(faceName) && layout == "normal" && lastChar.isLetter()) { allNameProps.contains(faceName) && layout == "normal" && lastChar.isLetter()) {
numComponent = " (" + QString(lastChar).toLower() + ")"; numComponent = " (" + QString(lastChar).toLower() + ")";
} }
allNameProps.append(faceName); allNameProps.insert(faceName);
// special handling properties // special handling properties
QString colors = card.value("colors").toStringList().join(""); QString colors;
for (const QJsonValue &color : card.value("colors").toArray()) {
colors += color.toString();
}
if (!colors.isEmpty()) { if (!colors.isEmpty()) {
properties.insert("colors", colors); properties.insert("colors", colors);
} }
// special handling properties QString colorIdentity;
QString colorIdentity = card.value("colorIdentity").toStringList().join(""); for (const QJsonValue &color : card.value("colorIdentity").toArray()) {
colorIdentity += color.toString();
}
if (!colorIdentity.isEmpty()) { if (!colorIdentity.isEmpty()) {
properties.insert("coloridentity", colorIdentity); properties.insert("coloridentity", colorIdentity);
} }
const auto &mainCardType = getMainCardType(card.value("types").toStringList()); const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList());
if (mainCardType.isEmpty()) { if (mainCardType.isEmpty()) {
qDebug() << "warning: no mainCardType for card:" << name; qDebug() << "warning: no mainCardType for card:" << name;
} else { } else {
@ -354,22 +359,22 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QList
// Depending on whether power and/or toughness are present, the format // Depending on whether power and/or toughness are present, the format
// is either P/T (most common), P (no toughness), or /T (no power). // is either P/T (most common), P (no toughness), or /T (no power).
QString power = getStringPropertyFromMap(card, "power"); QString power = getJsonString(card, "power");
QString toughness = getStringPropertyFromMap(card, "toughness"); QString toughness = getJsonString(card, "toughness");
if (toughness.isEmpty() && !power.isEmpty()) { if (toughness.isEmpty() && !power.isEmpty()) {
properties.insert("pt", power); properties.insert("pt", power);
} else if (!toughness.isEmpty()) { } else if (!toughness.isEmpty()) {
properties.insert("pt", power + ptSeparator + toughness); properties.insert("pt", power + ptSeparator + toughness);
} }
auto legalities = card.value("legalities").toMap(); auto legalities = card.value("legalities").toObject();
for (auto i = legalities.cbegin(), end = legalities.cend(); i != end; ++i) { for (auto i = legalities.constBegin(), end = legalities.constEnd(); i != end; ++i) {
properties.insert(QString("format-%1").arg(i.key()), i.value().toString().toLower()); properties.insert(QString("format-%1").arg(i.key()), i.value().toString().toLower());
} }
// split cards are considered a single card, enqueue for later merging // split cards are considered a single card, enqueue for later merging
if (layout == "split" || layout == "aftermath" || layout == "adventure" || layout == "prepare") { if (layout == "split" || layout == "aftermath" || layout == "adventure" || layout == "prepare") {
auto _faceName = getStringPropertyFromMap(card, "faceName"); auto _faceName = getJsonString(card, "faceName");
SplitCardPart split(_faceName, text, properties, printingInfo); SplitCardPart split(_faceName, text, properties, printingInfo);
auto found_iter = splitCards.find(name + numProperty); auto found_iter = splitCards.find(name + numProperty);
if (found_iter == splitCards.end()) { if (found_iter == splitCards.end()) {
@ -382,11 +387,11 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QList
QList<CardRelation *> relatedCards; QList<CardRelation *> relatedCards;
// add other face for split cards as card relation // add other face for split cards as card relation
if (!getStringPropertyFromMap(card, "side").isEmpty()) { if (!getJsonString(card, "side").isEmpty()) {
auto faceManaValue = getStringPropertyFromMap(card, "faceManaValue"); auto faceManaValue = getJsonString(card, "faceManaValue");
if (faceManaValue.isEmpty()) { if (faceManaValue.isEmpty()) {
// check the old name for the property, for backwards compatibility purposes // check the old name for the property, for backwards compatibility purposes
faceManaValue = getStringPropertyFromMap(card, "faceConvertedManaCost"); faceManaValue = getJsonString(card, "faceConvertedManaCost");
} }
properties["cmc"] = faceManaValue; properties["cmc"] = faceManaValue;
@ -406,20 +411,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QList
name = faceName; name = faceName;
} }
// mtgjon related cards // mtgjson related cards
if (card.contains("relatedCards")) { QJsonObject givenRelated = card.value("relatedCards").toObject();
QVariantMap givenRelated = card.value("relatedCards").toMap(); if (!givenRelated.isEmpty()) {
// conjured cards from a spellbook // conjured cards from a spellbook
if (givenRelated.contains("spellbook")) { QJsonArray spellbook = givenRelated.value("spellbook").toArray();
auto spbk = givenRelated.value("spellbook").toStringList(); if (!spellbook.isEmpty()) {
for (const QString &spbkName : spbk) { for (const QJsonValue &spbkVal : spellbook) {
relatedCards.append( relatedCards.append(new CardRelation(spbkVal.toString(), CardRelationType::DoesNotAttach, false,
new CardRelation(spbkName, CardRelationType::DoesNotAttach, false, false, 1, true)); false, 1, true));
} }
} }
} }
CardInfoPtr newCard = addCard(name + numComponent, text, isToken, properties, relatedCards, printingInfo); CardInfoPtr newCard =
addCard(name + numComponent, text, isToken, std::move(properties), relatedCards, printingInfo);
numCards++; numCards++;
} }
} }
@ -427,7 +433,6 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QList
// split cards handling // split cards handling
static const QString splitCardPropSeparator = QString(" // "); static const QString splitCardPropSeparator = QString(" // ");
static const QString splitCardTextSeparator = QString("\n\n---\n\n"); static const QString splitCardTextSeparator = QString("\n\n---\n\n");
static const QList<CardRelation *> noRelatedCards = {};
QList<QPair<QList<SplitCardPart>, QString>> partsAndNames = splitCards.values(); QList<QPair<QList<SplitCardPart>, QString>> partsAndNames = splitCards.values();
for (auto [splitCardParts, name] : partsAndNames) { for (auto [splitCardParts, name] : partsAndNames) {
@ -453,7 +458,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QList
if (!thisCardPropertyValue.isEmpty() && originalPropertyValue != thisCardPropertyValue) { if (!thisCardPropertyValue.isEmpty() && originalPropertyValue != thisCardPropertyValue) {
if (originalPropertyValue.isEmpty()) { // don't create //es if one field is empty if (originalPropertyValue.isEmpty()) { // don't create //es if one field is empty
properties.insert(prop, thisCardPropertyValue); properties.insert(prop, thisCardPropertyValue);
} else if (prop == "colors") { // the card is both colors } else if (prop == "colors" || prop == "coloridentity") { // the card is both colors
properties.insert(prop, originalPropertyValue + thisCardPropertyValue); properties.insert(prop, originalPropertyValue + thisCardPropertyValue);
} else if (prop == "maintype") { // don't create maintypes with //es in them } else if (prop == "maintype") { // don't create maintypes with //es in them
continue; continue;
@ -465,20 +470,20 @@ int OracleImporter::importCardsFromSet(const CardSetPtr &currentSet, const QList
} }
} }
} }
CardInfoPtr newCard = addCard(name, text, isToken, properties, noRelatedCards, printingInfo); CardInfoPtr newCard = addCard(name, text, isToken, std::move(properties), {}, printingInfo);
numCards++; numCards++;
} }
return numCards; return numCards;
} }
FormatRulesNameMap OracleImporter::createDefaultMagicFormats() static FormatRulesNameMap buildDefaultMagicFormats()
{ {
// Predefined common exceptions // Predefined common exceptions
CardCondition superTypeIsBasic; CardCondition superTypeIsBasic;
superTypeIsBasic.field = "type"; superTypeIsBasic.field = "type";
superTypeIsBasic.matchType = "regex"; superTypeIsBasic.matchType = "regex";
superTypeIsBasic.value = "\bBasic\b[^—]+\bLand\b"; superTypeIsBasic.value = R"(\bBasic\b[^—]+\bLand\b)";
ExceptionRule basicLands; ExceptionRule basicLands;
basicLands.conditions.append(superTypeIsBasic); basicLands.conditions.append(superTypeIsBasic);
@ -491,7 +496,6 @@ FormatRulesNameMap OracleImporter::createDefaultMagicFormats()
ExceptionRule mayContainAnyNumber; ExceptionRule mayContainAnyNumber;
mayContainAnyNumber.conditions.append(anyNumberAllowed); mayContainAnyNumber.conditions.append(anyNumberAllowed);
// Map to store default rules
FormatRulesNameMap defaultFormatRulesNameMap; FormatRulesNameMap defaultFormatRulesNameMap;
// ----------------- Helper lambda to create format ----------------- // ----------------- Helper lambda to create format -----------------
@ -537,10 +541,27 @@ FormatRulesNameMap OracleImporter::createDefaultMagicFormats()
return defaultFormatRulesNameMap; return defaultFormatRulesNameMap;
} }
const FormatRulesNameMap &OracleImporter::createDefaultMagicFormats()
{
static const FormatRulesNameMap cached = buildDefaultMagicFormats();
return cached;
}
int OracleImporter::startImport() int OracleImporter::startImport()
{ {
static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController(); static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController();
// Pre-allocate the cards hash to avoid rehashing during import. Keys are
// distinct card names while raw ranges only count printings (AllPrintings
// ~100k printings vs ~35k names), so this over-reserves somewhat; an exact
// distinct-name count would require eagerly parsing, which the lazy reader
// deliberately avoids. It's a capacity hint, so the overshoot is harmless.
int estimatedCards = 0;
for (const SetToDownload &curSetToParse : allSets) {
estimatedCards += curSetToParse.getRawRange().cardCount;
}
cards.reserve(estimatedCards);
// add an empty set for tokens // add an empty set for tokens
CardSetPtr tokenSet = CardSetPtr tokenSet =
CardSet::newInstance(noOpController, CardSet::TOKENS_SETNAME, tr("Dummy set containing tokens"), "Tokens"); CardSet::newInstance(noOpController, CardSet::TOKENS_SETNAME, tr("Dummy set containing tokens"), "Tokens");
@ -552,11 +573,44 @@ int OracleImporter::startImport()
CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(), CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(),
curSetToParse.getLongName(), curSetToParse.getSetType(), curSetToParse.getLongName(), curSetToParse.getSetType(),
curSetToParse.getReleaseDate(), curSetToParse.getPriority()); curSetToParse.getReleaseDate(), curSetToParse.getPriority());
// parse only this set's slice of the raw document so the whole JSON tree is
// never kept in memory at once
const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange();
const qsizetype rangeEnd = rawRange.start + rawRange.length;
if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) {
// rawSetsData is cleared by releaseSetData() while SetToDownload copies
// taken from getSets() keep their ranges, and nothing else enforces the
// pairing — so never index past the buffer on stale/mismatched ranges.
qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping";
++setIndex;
emit setIndexChanged(0, setIndex, curSetToParse.getLongName());
continue;
}
// sliced() shares the buffer instead of deep-copying the slice; the largest
// sets in AllPrintings are tens of MB, so the copy is worth avoiding here.
const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length);
QJsonParseError parseError;
const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError);
if (parseError.error != QJsonParseError::NoError) {
qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":"
<< parseError.errorString();
++setIndex;
// Keep the progress accounting honest: a set that failed to parse
// still advanced the index, so report it (with zero imported cards)
// rather than letting SaveSetsPage's bar stall per failed set.
emit setIndexChanged(0, setIndex, curSetToParse.getLongName());
continue;
}
// Only add the set to the database once its slice parsed cleanly;
// a set that fails here must not persist as an empty set in cards.xml.
if (!sets.contains(newSet->getShortName())) { if (!sets.contains(newSet->getShortName())) {
sets.insert(newSet->getShortName(), newSet); sets.insert(newSet->getShortName(), newSet);
} }
int numCardsInSet = importCardsFromSet(newSet, curSetToParse.getCards()); const QJsonArray setCards = setDoc.object().value("cards").toArray();
int numCardsInSet = importCardsFromSet(newSet, setCards);
++setIndex; ++setIndex;
@ -576,9 +630,16 @@ bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUr
return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion); return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion);
} }
void OracleImporter::releaseSetData()
{
allSets.clear();
rawSetsData.clear();
}
void OracleImporter::clear() void OracleImporter::clear()
{ {
sets.clear(); sets.clear();
cards.clear(); cards.clear();
allSets.clear(); allSets.clear();
rawSetsData.clear();
} }

View file

@ -1,6 +1,11 @@
#ifndef ORACLEIMPORTER_H #ifndef ORACLEIMPORTER_H
#define ORACLEIMPORTER_H #define ORACLEIMPORTER_H
#include "raw_json_scanner.h"
#include <QByteArray>
#include <QJsonArray>
#include <QJsonObject>
#include <QMap> #include <QMap>
#include <QRegularExpression> #include <QRegularExpression>
#include <QVariant> #include <QVariant>
@ -44,10 +49,12 @@ class SetToDownload
{ {
private: private:
QString shortName, longName; QString shortName, longName;
QList<QVariant> cards;
QDate releaseDate; QDate releaseDate;
QString setType; QString setType;
CardSet::Priority priority; CardSet::Priority priority;
// Byte range of this set's object within the importer's raw JSON text. Parsing
// one set at a time keeps peak memory low instead of holding the whole document.
RawJson::SetDataRange rawRange;
public: public:
const QString &getShortName() const const QString &getShortName() const
@ -58,10 +65,6 @@ public:
{ {
return longName; return longName;
} }
const QList<QVariant> &getCards() const
{
return cards;
}
const QString &getSetType() const const QString &getSetType() const
{ {
return setType; return setType;
@ -74,16 +77,23 @@ public:
{ {
return priority; return priority;
} }
const RawJson::SetDataRange &getRawRange() const
{
return rawRange;
}
SetToDownload(QString _shortName, SetToDownload(QString _shortName,
QString _longName, QString _longName,
QList<QVariant> _cards,
CardSet::Priority _priority, CardSet::Priority _priority,
QString _setType = QString(), QString _setType = QString(),
const QDate &_releaseDate = QDate()) const QDate &_releaseDate = QDate())
: shortName(std::move(_shortName)), longName(std::move(_longName)), cards(std::move(_cards)), : shortName(std::move(_shortName)), longName(std::move(_longName)), releaseDate(_releaseDate),
releaseDate(_releaseDate), setType(std::move(_setType)), priority(_priority) setType(std::move(_setType)), priority(_priority)
{ {
} }
void setRawRange(const RawJson::SetDataRange &_rawRange)
{
rawRange = _rawRange;
}
bool operator<(const SetToDownload &set) const bool operator<(const SetToDownload &set) const
{ {
return longName.compare(set.longName, Qt::CaseInsensitive) < 0; return longName.compare(set.longName, Qt::CaseInsensitive) < 0;
@ -139,6 +149,12 @@ private:
QList<SetToDownload> allSets; QList<SetToDownload> allSets;
/**
* The raw JSON text of the source document, retained for lazy per-set
* parsing during startImport(). Frees the card data as each set is imported.
*/
QByteArray rawSetsData;
CardInfoPtr addCard(QString name, CardInfoPtr addCard(QString name,
const QString &text, const QString &text,
bool isToken, bool isToken,
@ -151,11 +167,18 @@ signals:
public: public:
explicit OracleImporter(QObject *parent = nullptr); explicit OracleImporter(QObject *parent = nullptr);
bool readSetsFromByteArray(const QByteArray &data); /**
* Scans the given JSON document for set metadata. Takes the data by value so
* the wizard can hand over its decompressed buffer without copying it.
*/
bool readSetsFromByteArray(QByteArray data);
int startImport(); int startImport();
bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion);
int importCardsFromSet(const CardSetPtr &currentSet, const QList<QVariant> &cardsList); int importCardsFromSet(const CardSetPtr &currentSet, const QJsonArray &cardsList);
FormatRulesNameMap createDefaultMagicFormats(); /**
* @brief Returns the default format rules. The result is memoized on first use and must be treated as immutable.
*/
const FormatRulesNameMap &createDefaultMagicFormats();
const CardNameMap &getCardList() const const CardNameMap &getCardList() const
{ {
return cards; return cards;
@ -164,6 +187,11 @@ public:
{ {
return allSets; return allSets;
} }
const QByteArray &getRawSetsData() const
{
return rawSetsData;
}
void releaseSetData();
void clear(); void clear();
}; };

View file

@ -560,6 +560,9 @@ void SaveSetsPage::initializePage()
int setsImported = wizard()->importer->startImport(); int setsImported = wizard()->importer->startImport();
// JSON data no longer needed after CardInfo objects are built
wizard()->importer->releaseSetData();
if (setsImported == 0) { if (setsImported == 0) {
QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); QMessageBox::critical(this, tr("Error"), tr("No set has been imported."));
} }

View file

@ -1,3 +0,0 @@
Eeli Reilin <eeli@emicode.fi>
Luis Gustavo S. Barreto <gustavosbarreto@gmail.com>
Stephen Kockentiedt <Stephen@Kockentiedt.name>

View file

@ -1,27 +0,0 @@
Copyright 2011 Eeli Reilin. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY <COPYRIGHT HOLDER> ''AS IS'' AND ANY EXPRESS OR
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
The views and conclusions contained in the software and documentation
are those of the authors and should not be interpreted as representing
official policies, either expressed or implied, of Eeli Reilin.

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