Merge branch 'master' into tooomm-patch-33

This commit is contained in:
tooomm 2026-09-12 16:35:36 +02:00 committed by GitHub
commit 860ff503b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
233 changed files with 7900 additions and 1711 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

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

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

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

@ -4,8 +4,8 @@
# Cockatrice, Oracle, Servatrice, Test # Cockatrice, Oracle, Servatrice, Test
# This file sets all the variables shared between the projects like the installation path, compilation flags etc.. # This file sets all the variables shared between the projects like the installation path, compilation flags etc..
# CMake 3.16 is required if using Qt6 # CMake 3.16 is required for Qt6 and target_precompile_headers()
cmake_minimum_required(VERSION 3.10) cmake_minimum_required(VERSION 3.16)
# Compile Cockatrice # Compile Cockatrice
option(WITH_CLIENT "Build Cockatrice client" ON) option(WITH_CLIENT "Build Cockatrice client" ON)
@ -18,10 +18,10 @@ option(TEST "Build tests" OFF)
# Check for translation updates # Check for translation updates
option(UPDATE_TRANSLATIONS "Update translations on compile" OFF) option(UPDATE_TRANSLATIONS "Update translations on compile" OFF)
# Use compiler cache (ccache)
option(USE_CCACHE "Cache the build results with ccache" ON)
# Use vcpkg regardless of OS # Use vcpkg regardless of OS
option(USE_VCPKG "Use vcpkg regardless of OS" OFF) option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
# Use compiler cache (ccache)
option(USE_CCACHE "Cache the build results with ccache" OFF)
# Treat warnings as errors (Debug builds only) # 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)
@ -34,13 +34,24 @@ if(NOT CMAKE_BUILD_TYPE)
) )
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) if(WIN32 OR USE_VCPKG)
@ -205,6 +216,9 @@ elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") # GCC compiler
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")
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang compiler elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang compiler
# -O2 Balanced optimization # -O2 Balanced optimization
set(CMAKE_CXX_FLAGS_RELEASE "-O2") set(CMAKE_CXX_FLAGS_RELEASE "-O2")
@ -221,11 +235,13 @@ elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang compiler
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") # <-- required for clang? see adding pr description again
else() # Undefined compiler else() # Undefined compiler
message(WARNING message(WARNING
"Unknown C++ compiler: ${CMAKE_CXX_COMPILER_ID}" "Unknown C++ compiler: ${CMAKE_CXX_COMPILER_ID}"
) )
endif() endif()
# GNU systems need to define the Mersenne Exponent for SFMT for the RNG to compile without warning # GNU systems need to define the Mersenne Exponent for SFMT for the RNG to compile without warning
@ -274,11 +290,6 @@ if(WIN32) # Windows (including 64bit)
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/*

View file

@ -150,7 +150,7 @@ You can then
The following flags (with their non-default values) can be passed to `cmake`: The following flags (with their non-default values) can be passed to `cmake`:
| Flag | Description | | Flag | Description |
| --- | --- | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `-DWITH_SERVER=1` | Build <kbd>Servatrice</kbd> server | | `-DWITH_SERVER=1` | Build <kbd>Servatrice</kbd> server |
| `-DWITH_CLIENT=0` | Don't build <kbd>Cockatrice</kbd> client | | `-DWITH_CLIENT=0` | Don't build <kbd>Cockatrice</kbd> client |
| `-DWITH_ORACLE=0` | Don't build <kbd>Oracle</kbd> card database tool | | `-DWITH_ORACLE=0` | Don't build <kbd>Oracle</kbd> card database tool |

View file

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

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

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

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

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

View file

@ -166,6 +166,7 @@ set(cockatrice_SOURCES
src/interface/widgets/cards/additional_info/mana_cost_widget.cpp src/interface/widgets/cards/additional_info/mana_cost_widget.cpp
src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp
src/interface/widgets/cards/art_crop_attribution.cpp src/interface/widgets/cards/art_crop_attribution.cpp
src/interface/widgets/cards/card_art_utils.cpp
src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp
src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp
src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp
@ -214,6 +215,7 @@ set(cockatrice_SOURCES
src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp src/interface/widgets/deck_editor/deck_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
@ -380,6 +382,7 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_card_art_rules.cpp
src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_editor.cpp
src/interface/widgets/tabs/tab_deck_storage.cpp src/interface/widgets/tabs/tab_deck_storage.cpp
src/interface/widgets/tabs/tab_developer.cpp
src/interface/widgets/tabs/tab_game.cpp src/interface/widgets/tabs/tab_game.cpp
src/interface/widgets/tabs/tab_home.cpp src/interface/widgets/tabs/tab_home.cpp
src/interface/widgets/tabs/tab_logs.cpp src/interface/widgets/tabs/tab_logs.cpp
@ -516,6 +519,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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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;
@ -216,7 +221,12 @@ void GameScene::removePlayer(PlayerLogic *player)
clearArrowsForPlayer(player->getPlayerInfo()->getId()); clearArrowsForPlayer(player->getPlayerInfo()->getId());
for (ZoneViewWidget *zone : zoneViews) { // Closing a view removes it from zoneViews synchronously, so iterate over a
// copy: otherwise a player with several open views (e.g. library and hand)
// only has the first one closed here and the remaining views are left
// pointing at a player that is about to be deleted.
const QList<ZoneViewWidget *> zoneViewCopy = zoneViews;
for (ZoneViewWidget *zone : zoneViewCopy) {
if (zone->getPlayer() == player) { if (zone->getPlayer() == player) {
zone->close(); zone->close();
} }
@ -659,7 +669,10 @@ CardItem *GameScene::findTopmostCardInZone(const QList<QGraphicsItem *> &items,
*/ */
void GameScene::toggleZoneView(PlayerLogic *player, const QString &zoneName, int numberCards, bool isReversed) void GameScene::toggleZoneView(PlayerLogic *player, const QString &zoneName, int numberCards, bool isReversed)
{ {
for (auto &view : zoneViews) { // Closing a view removes it from zoneViews synchronously, so iterate over a
// copy to make sure every already-open matching view is closed.
const QList<ZoneViewWidget *> zoneViewCopy = zoneViews;
for (auto *view : zoneViewCopy) {
ZoneViewZone *temp = view->getZone(); ZoneViewZone *temp = view->getZone();
if (temp->getLogic()->getName() == zoneName && temp->getLogic()->getPlayer() == player && if (temp->getLogic()->getName() == zoneName && temp->getLogic()->getPlayer() == player &&
qobject_cast<ZoneViewZoneLogic *>(temp->getLogic())->getNumberCards() == numberCards) { qobject_cast<ZoneViewZoneLogic *>(temp->getLogic())->getNumberCards() == numberCards) {
@ -777,8 +790,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 +817,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,6 +54,8 @@ 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
QHash<QObject *, QMetaObject::Connection>
animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item
int playerRotation; ///< Rotation offset for player layout int playerRotation; ///< Rotation offset for player layout
bool rearranging = false; ///< Guard against re-entrant rearrange bool rearranging = false; ///< Guard against re-entrant rearrange
bool needsReArrange = false; ///< Pending rearrange requested during a pass bool needsReArrange = false; ///< Pending rearrange requested during a pass

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -34,3 +34,31 @@ QList<TallyRow> StatsTally::computeTotalPower(const QList<CardItem *> &cards)
QString name = QCoreApplication::translate("StatsTally", "Total Power"); 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

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

View file

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

View file

@ -375,15 +375,32 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL
void DeckLoader::saveToStream_DeckZone(QTextStream &out, 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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -174,17 +174,19 @@ 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));
}
}
void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent)
{
// Get the card amount // Get the card amount
int cardAmount = int cardAmount = persistent.sibling(persistent.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt();
sourceIndex.sibling(sourceIndex.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));
} }
}
} }
void CardGroupDisplayWidget::clearAllDisplayWidgets() void CardGroupDisplayWidget::clearAllDisplayWidgets()

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

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

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

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

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

View file

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

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

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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