Compare commits

...

4 commits

Author SHA1 Message Date
BruebachL
08d6b51db9
[Server] Add deck validation strategy interface (#7129)
Some checks are pending
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 15 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker Image / amd64 & arm64 (push) Waiting to run
Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-17 11:21:25 +02:00
BruebachL
a8bacc5296
[VDS] Add async-scanned model and sort/filter proxy (#7105)
* [VDS] Add async-scanned model and sort/filter proxy (model layer)


Took 1 minute

Took 27 seconds

* Address comments

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-17 11:20:26 +02:00
BruebachL
765ebf8fb1
[UserList] Context menu invite (#7138)
* [Client] Send game invites from the user context menu via a private message

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

Took 50 seconds


Took 3 minutes

* [Client] Extract sendPrivateMessage() to fix invite message draft overwrite

sendInviteMessage() was calling sayEdit->setText(text) then sendMessage(),
which overwrites any text the user had typed.  Extract the command-building
and sending logic into a new sendPrivateMessage(const QString &text) method
that takes the text directly.  sendMessage() now calls it after its guards
and clears sayEdit; sendInviteMessage() calls it directly without touching
the input field at all.

Took 33 minutes

* Rename method, address comments.

Took 5 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-17 10:49:19 +02:00
BruebachL
776f917ffc
[Client] Confirm before joining a game opened from a game link (#7137)
* [Client] Confirm before joining a game opened from a game link

Joining a game from a cockatrice://joingame link is a navigation
decision, so restate what will be joined and ask before acting:
the confirm names the game description when the link carries one
(falling back to the room name and numeric id for older links), and
reports the host:port so links that point at a different server are
obvious. The intent chain is only started after confirmation.


Took 3 minutes

* [Client] Extract the join-game confirm message into a helper

Took 3 minutes

# Commit time for manual adjustment:
# Took 6 seconds

* Proper fwd declare.

Took 3 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-08-17 09:37:51 +02:00
20 changed files with 1328 additions and 15 deletions

View file

@ -301,8 +301,10 @@ set(cockatrice_SOURCES
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp

View file

@ -1,5 +1,7 @@
#include "url_parser.h"
#include "../widgets/tabs/tab_room.h"
#include "../widgets/tabs/tab_supervisor.h"
#include "../window_main.h"
#include "contexts/context_join_game.h"
#include "intent_join_server_game.h"
@ -9,6 +11,7 @@
#include <QMessageBox>
#include <QUrl>
#include <QUrlQuery>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <memory>
IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow)
@ -71,6 +74,15 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
return;
}
const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded);
const QString message = generateJoinGameMessage(*ctx, gameDescription);
const QMessageBox::StandardButton answer = QMessageBox::question(
mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
if (answer != QMessageBox::Yes) {
return;
}
// The join game intent owns the context and the credential lookup; once the
// chain finishes (or fails) it deletes the whole tree.
ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
@ -87,3 +99,38 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
getLoginCredentialsIntent->execute();
}
QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription)
{
const QString hostname = context.roomContext.serverContext.hostname;
const QString port = context.roomContext.serverContext.port;
const int roomId = context.roomContext.roomId;
const int gameId = context.gameId;
const QString server = QStringLiteral("%1:%2").arg(hostname, port);
// Prefer the room name over the raw numeric id: it means something to the
// user. The name is only known when we are already connected to the same
// server and sitting in that room — otherwise fall back to a plain prompt.
AbstractClient *client = mainWindow->getTabSupervisor()->getClient();
const bool sameServer = client != nullptr && client->getStatus() == StatusLoggedIn &&
hostname.compare(client->serverName(), Qt::CaseInsensitive) == 0 &&
QString::number(client->serverPort()) == port;
TabRoom *roomTab = sameServer ? mainWindow->getTabSupervisor()->getRoomTabs().value(roomId) : nullptr;
const QString gameIdStr = QString::number(gameId);
// Links built by newer clients embed the game description ("game" item);
// restate it in the confirm so it matches what the chat anchor showed.
// Unknown query items are ignored, so old links without it keep working.
// The multi-arg .arg() overloads replace in a single pass, so a description
// containing "%…" cannot corrupt later placeholders.
// FullyDecoded undoes every %XX escape and must match the chat anchor's
// decode mode, so a description containing "%" reads identically in both.
if (gameDescription.isEmpty()) {
return roomTab ? tr("Join game #%1 in \"%2\" on %3?").arg(gameIdStr, roomTab->getRoomName(), server)
: tr("Join game #%1 on %2?").arg(gameIdStr, server);
}
return roomTab ? tr("Join game \"%1\" (#%2) in \"%3\" on %4?")
.arg(gameDescription, gameIdStr, roomTab->getRoomName(), server)
: tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server);
}

View file

@ -4,6 +4,7 @@
#include <QUrlQuery>
class MainWindow;
struct ContextJoinGame;
class IntentUrlParser : public QObject
{
Q_OBJECT
@ -14,6 +15,8 @@ public:
void handleJoinGame(const QUrlQuery &query);
private:
QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription);
MainWindow *mainWindow;
};

View file

@ -355,6 +355,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
{
QAction *aCopyToClipBoard = nullptr, *aRemoveMessages = nullptr;
aUserName->setText(userName);
const bool anotherUser = userName != userListProxy->getOwnUsername();
auto *menu = new QMenu(static_cast<QWidget *>(parent()));
menu->addAction(aUserName);
@ -366,6 +367,17 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
menu->addAction(aDetails);
menu->addAction(aShowGames);
menu->addAction(aChat);
const QList<GameInviteOption> inviteOptions = inviteOptionsForUser(userName);
if (!inviteOptions.isEmpty()) {
auto *inviteMenu = new QMenu(tr("&Invite to Game"), menu);
for (const GameInviteOption &option : inviteOptions) {
QAction *inviteAction = inviteMenu->addAction(option.label);
inviteAction->setEnabled(anotherUser && online);
connect(inviteAction, &QAction::triggered, this,
[this, userName, option] { execInvite(userName, option); });
}
menu->addMenu(inviteMenu);
}
if (userLevel.testFlag(ServerInfo_User::IsRegistered) && userListProxy->isOwnUserRegistered()) {
menu->addSeparator();
if (userListProxy->isUserBuddy(userName)) {
@ -416,7 +428,6 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
menu->addAction(aPromoteToJudge);
}
}
bool anotherUser = userName != userListProxy->getOwnUsername();
aDetails->setEnabled(true);
aChat->setEnabled(anotherUser && online);
aShowGames->setEnabled(online);
@ -480,6 +491,60 @@ void UserContextMenu::execChat(const QString &userName)
emit openMessageDialog(userName, true);
}
QList<GameInviteOption> UserContextMenu::inviteOptionsForUser(const QString &userName) const
{
if (!gameInviteLinkProvider) {
return {};
}
const QList<GameInviteOption> options = gameInviteLinkProvider();
QList<GameInviteOption> result;
for (const GameInviteOption &option : options) {
// Buddy-only games accept invites only from their creator, and only to
// users on the creator's buddy list.
if (option.onlyBuddies &&
(option.creatorName != userListProxy->getOwnUsername() || !userListProxy->isUserBuddy(userName))) {
continue;
}
result.append(option);
}
return result;
}
void UserContextMenu::execInvite(const QString &userName)
{
const QList<GameInviteOption> options = inviteOptionsForUser(userName);
if (options.isEmpty()) {
return;
}
if (options.size() == 1) {
execInvite(userName, options.first());
return;
}
// More than one game in the room — let the user pick which one to invite to.
auto *menu = new QMenu(static_cast<QWidget *>(parent()));
for (const GameInviteOption &option : options) {
QAction *action = menu->addAction(option.label);
connect(action, &QAction::triggered, this, [this, userName, option] { execInvite(userName, option); });
}
menu->setAttribute(Qt::WA_DeleteOnClose);
menu->popup(QCursor::pos());
}
void UserContextMenu::execInvite(const QString &userName, const GameInviteOption &option)
{
// Name the game by description first, then its id — "Join my game 'Magic'
// (#123)" — so a description-less fallback still identifies the game.
// The multi-arg .arg() overloads replace in a single pass, so a description
// containing "%…" cannot corrupt later placeholders.
const QString prefix =
option.description.isEmpty()
? tr("Join my game (#%1):").arg(option.gameId)
: tr("Join my game \"%1\" (#%2):").arg(option.description, QString::number(option.gameId));
tabSupervisor->sendInviteToUser(userName, prefix + " " + option.url);
}
void UserContextMenu::execDetails(const QString &userName)
{
auto *w = new UserInfoBox(client, false, static_cast<QWidget *>(parent()),

View file

@ -7,9 +7,12 @@
#ifndef USER_CONTEXT_MENU_H
#define USER_CONTEXT_MENU_H
#include <QObject>
#include <libcockatrice/network/server/remote/user_level.h>
#include "../../interface/widgets/server/game_link.h"
#include <QList>
#include <QObject>
#include <functional>
#include <libcockatrice/network/server/remote/user_level.h>
class AbstractGame;
class UserListProxy;
class AbstractClient;
@ -43,6 +46,7 @@ private:
QAction *aPromoteToJudge, *aDemoteFromJudge;
QAction *aWarnUser, *aWarnHistory;
QAction *aGetAdminNotes;
std::function<QList<GameInviteOption>()> gameInviteLinkProvider;
signals:
void openMessageDialog(const QString &userName, bool focus);
private slots:
@ -80,9 +84,28 @@ public:
return userListProxy;
}
void setGameInviteLinkProvider(std::function<QList<GameInviteOption>()> provider)
{
gameInviteLinkProvider = std::move(provider);
}
/**
* The games currently inviteable for @p userName, honoring the room's
* buddy-only setting (the inviter must be the game's creator and the
* target a buddy of theirs). Empty when there is no live provider.
*/
QList<GameInviteOption> inviteOptionsForUser(const QString &userName) const;
/** Whether at least one invite link is currently available for @p userName. */
bool hasGameInviteLink(const QString &userName) const
{
return !inviteOptionsForUser(userName).isEmpty();
}
// Individual action entry points — used by UserInfoPopup to trigger
// actions without re-running the full context menu flow.
void execChat(const QString &userName);
void execInvite(const QString &userName);
void execDetails(const QString &userName);
void execShowGames(const QString &userName);
void execAddToBuddy(const QString &userName);
@ -97,6 +120,9 @@ public:
void execAdminNotes(const QString &userName);
void execAdjustMod(const QString &userName, bool shouldBeMod);
void execAdjustJudge(const QString &userName, bool shouldBeJudge);
private:
void execInvite(const QString &userName, const GameInviteOption &option);
};
#endif

View file

@ -1838,3 +1838,8 @@ void UserListWidget::finishSectionedMutation()
applyFilter();
userTree->viewport()->update();
}
void UserListWidget::setGameInviteLinkProvider(std::function<QList<GameInviteOption>()> provider)
{
userContextMenu->setGameInviteLinkProvider(std::move(provider));
}

View file

@ -8,6 +8,7 @@
#define USERLIST_H
#include "../../cards/card_info_picture_art_crop_widget.h"
#include "../../interface/widgets/server/game_link.h"
#include "user_avatar_provider.h"
#include "user_card_art_provider.h"
#include "user_info_popup.h"
@ -22,6 +23,7 @@
#include <QStyledItemDelegate>
#include <QTextEdit>
#include <QTreeWidgetItem>
#include <functional>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
@ -271,6 +273,7 @@ public:
}
void showContextMenu(const QPoint &pos, const QModelIndex &index);
void sortItems();
void setGameInviteLinkProvider(std::function<QList<GameInviteOption>()> provider);
protected:
void hideEvent(QHideEvent *e) override;

View file

@ -96,6 +96,18 @@ void TabMessage::closeEvent(QCloseEvent *event)
event->accept();
}
void TabMessage::sendPrivateMessage(const QString &text)
{
Command_Message cmd;
cmd.set_user_name(otherUserInfo->name());
cmd.set_message(text.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
pend->setExtraData(text);
connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent);
client->sendCommand(pend);
}
void TabMessage::sendMessage()
{
if (sayEdit->text().isEmpty()) {
@ -103,24 +115,19 @@ void TabMessage::sendMessage()
}
if (!userOnline) {
// Keep the draft: the user may be back momentarily, and the typed text
// should not be lost to a transient offline spell.
notifyUserOffline();
return;
}
Command_Message cmd;
cmd.set_user_name(otherUserInfo->name());
cmd.set_message(sayEdit->text().toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
pend->setExtraData(sayEdit->text());
connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent);
client->sendCommand(pend);
sendPrivateMessage(sayEdit->text());
sayEdit->clear();
}
bool TabMessage::isUserOnline() const
{
return userOnline;
}
void TabMessage::messageSent(const Response &response,
const CommandContainer & /*commandContainer*/,
const QVariant &extraData)

View file

@ -64,6 +64,9 @@ public:
void processUserLeft();
void processUserJoined(const ServerInfo_User &_userInfo);
[[nodiscard]] bool isUserOnline() const;
void sendPrivateMessage(const QString &text);
private:
bool shouldShowSystemPopup(const Event_UserMessage &event);
void showSystemPopup(const Event_UserMessage &event);

View file

@ -4,6 +4,7 @@
#include "../../../client/settings/shortcuts_settings.h"
#include "../interface/widgets/dialogs/dlg_settings.h"
#include "../interface/widgets/server/chat_view/chat_view.h"
#include "../interface/widgets/server/game_link.h"
#include "../interface/widgets/server/game_selector.h"
#include "../interface/widgets/server/user/user_list_manager.h"
#include "../interface/widgets/server/user/user_list_panel_widget.h"
@ -30,6 +31,7 @@
#include <QToolButton>
#include <QVBoxLayout>
#include <QtCore/qdatetime.h>
#include <functional>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/get_pb_extension.h>
@ -66,6 +68,9 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
userList = userListPanel->getUserList();
connect(userListPanel, &UserListPanelWidget::openMessageDialog, this, &TabRoom::openMessageDialog);
const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); };
userList->setGameInviteLinkProvider(gameInviteLinkProvider);
chatView = new ChatView(tabSupervisor, nullptr, true, this);
connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup);
connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab);

View file

@ -3,6 +3,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../interface/pixel_map_generator.h"
#include "../interface/widgets/server/game_link.h"
#include "../interface/widgets/server/user/user_list_manager.h"
#include "../interface/widgets/server/user/user_list_widget.h"
#include "../main.h"
@ -950,6 +951,53 @@ void TabSupervisor::talkLeft(TabMessage *tab)
removeTab(indexOf(tab));
}
QList<GameInviteOption> TabSupervisor::getGameInviteLinksForRoom(int roomId) const
{
QList<GameInviteOption> options;
if (isLocalGame) {
return options;
}
// The inviter may be in several games of the same room (hosting one and
// spectating another, for example). Return every game so the caller can
// let the user choose which one to invite to.
for (TabGame *tab : gameTabs) {
GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo();
if (metaInfo->proto().room_id() != roomId) {
continue;
}
// A closed game is a dead end — drop it. Started/full games stay
// listed: an invite to them is a legitimate "come spectate" offer.
if (metaInfo->proto().closed()) {
continue;
}
const int gameId = metaInfo->gameId();
const QString description = QString::fromStdString(metaInfo->proto().description());
GameInviteOption option{
.gameId = gameId,
.label =
description.isEmpty() ? tr("Game #%1").arg(gameId) : tr("Game #%1 — %2").arg(gameId).arg(description),
.url = makeGameJoinLink(client->serverName(), client->serverPort(), roomId, gameId, description),
.description = description,
.onlyBuddies = metaInfo->proto().only_buddies(),
.creatorName = QString::fromStdString(metaInfo->proto().creator_info().name()),
};
options.append(option);
}
return options;
}
void TabSupervisor::sendInviteToUser(const QString &userName, const QString &inviteText)
{
TabMessage *tab = addMessageTab(userName, true);
if (tab && tab->isUserOnline()) {
tab->sendPrivateMessage(inviteText);
}
}
/**
* Creates a new deck editor tab and loads the deck into it.
* Creates either a classic or visual deck editor tab depending on settings

View file

@ -9,6 +9,7 @@
#define TAB_SUPERVISOR_H
#include "../../deck_loader/deck_loader.h"
#include "../interface/widgets/server/game_link.h"
#include "../interface/widgets/server/user/user_list_proxy.h"
#include "abstract_tab_deck_editor.h"
#include "api/archidekt/tab_archidekt.h"
@ -160,6 +161,8 @@ public:
{
return deckEditorTabs;
}
[[nodiscard]] QList<GameInviteOption> getGameInviteLinksForRoom(int roomId) const;
void sendInviteToUser(const QString &userName, const QString &inviteText);
[[nodiscard]] bool getAdminLocked() const;
void closeEvent(QCloseEvent *event) override;
bool switchToGameTabIfAlreadyExists(const int gameId);

View file

@ -0,0 +1,503 @@
#include "visual_deck_storage_model.h"
#include "../../deck_loader/deck_loader.h"
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QSet>
#include <QtConcurrentRun>
#include <libcockatrice/card/database/card_database_manager.h>
#include <utility>
namespace
{
/**
* @brief The result of a background directory scan: the deck rows in scan order
* plus the sorted list of subfolder paths.
*/
struct DeckScanResult
{
QList<DeckPreviewData> decks; ///< Deck rows in scan order.
QStringList folderPaths; ///< Sorted list of subfolder paths.
};
/**
* @brief The result of a background deck file load: the parsed deck plus the
* file's modification time, so the disk stat happens off the UI thread.
*/
struct DeckLoadResult
{
LoadedDeck deck; ///< The parsed deck.
QDateTime lastModified; ///< File modification time at load.
};
/**
* @brief The path of \a path relative to the deck root, or empty if \a path
* is not below it.
*/
QString relativePathFromDeckRoot(const QString &path, const QString &deckPath)
{
if (!path.startsWith(deckPath)) {
return {};
}
QString relativePath = path.mid(deckPath.length());
if (relativePath.startsWith('/')) {
relativePath.remove(0, 1);
}
return relativePath;
}
/**
* @brief The path of \a filePath relative to \a deckPath, or the bare file name
* if \a filePath is not below \a deckPath.
*/
QString relativeFilePathFor(const QString &filePath, const QString &deckPath)
{
if (filePath.startsWith(deckPath)) {
return filePath.mid(deckPath.length());
}
return QFileInfo(filePath).fileName();
}
/**
* @brief The directory of \a filePath relative to \a deckPath, or empty if the
* file sits directly in the deck root.
*/
QString folderPathFor(const QString &filePath, const QString &deckPath)
{
return relativePathFromDeckRoot(QFileInfo(filePath).absolutePath(), deckPath);
}
/**
* @brief Scans a deck directory on a worker thread, returning discovered deck
* files and subfolder paths.
*/
DeckScanResult scanDeckDirectory(const QString &deckPath)
{
DeckScanResult result;
QDirIterator fileIt(deckPath, DeckLoader::ACCEPTED_FILE_EXTENSIONS, QDir::Files,
QDirIterator::Subdirectories | QDirIterator::FollowSymlinks);
while (fileIt.hasNext()) {
const QString filePath = fileIt.next();
DeckPreviewData data;
data.filePath = filePath;
data.relativeFilePath = relativeFilePathFor(filePath, deckPath);
data.folderPath = folderPathFor(filePath, deckPath);
data.lastModified = QFileInfo(filePath).lastModified();
result.decks.append(std::move(data));
}
QSet<QString> seenFolders;
QDirIterator folderIt(deckPath, QDir::Dirs | QDir::NoDotAndDotDot,
QDirIterator::Subdirectories | QDirIterator::FollowSymlinks);
while (folderIt.hasNext()) {
const QString folderPath = relativePathFromDeckRoot(folderIt.next(), deckPath);
if (!folderPath.isEmpty() && !seenFolders.contains(folderPath)) {
seenFolders.insert(folderPath);
result.folderPaths.append(folderPath);
}
}
result.folderPaths.sort();
return result;
}
} // namespace
VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent)
{
}
int VisualDeckStorageModel::rowCount(const QModelIndex &parent) const
{
return parent.isValid() ? 0 : decks.size();
}
QVariant VisualDeckStorageModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() < 0 || index.row() >= decks.size()) {
return {};
}
const DeckPreviewData &data = decks.at(index.row());
switch (role) {
case Qt::DisplayRole:
case VisualDeckStorageRoles::DisplayNameRole:
return data.displayName;
case VisualDeckStorageRoles::FilePathRole:
return data.filePath;
case VisualDeckStorageRoles::RelativeFilePathRole:
return data.relativeFilePath;
case VisualDeckStorageRoles::FolderPathRole:
return data.folderPath;
case VisualDeckStorageRoles::TagsRole:
return data.tags;
case VisualDeckStorageRoles::ColorIdentityRole:
return data.colorIdentity;
case VisualDeckStorageRoles::LastModifiedRole:
return data.lastModified;
case VisualDeckStorageRoles::LastLoadedRole:
return data.lastLoaded;
case VisualDeckStorageRoles::BannerCardNameRole:
return data.bannerCard.name;
case VisualDeckStorageRoles::BannerCardProviderIdRole:
return data.bannerCard.providerId;
default:
return {};
}
}
void VisualDeckStorageModel::setDeckPath(const QString &path)
{
QString cleanedPath = QDir::cleanPath(path);
if (cleanedPath == ".") {
cleanedPath.clear();
}
deckPath = cleanedPath;
startScan();
}
void VisualDeckStorageModel::refresh()
{
startScan();
}
const DeckPreviewData &VisualDeckStorageModel::dataForRow(int row) const
{
static const DeckPreviewData emptyData;
if (row < 0 || row >= decks.size()) {
return emptyData;
}
return decks.at(row);
}
const LoadedDeck &VisualDeckStorageModel::deckForRow(int row) const
{
return dataForRow(row).deck;
}
int VisualDeckStorageModel::rowForFilePath(const QString &filePath) const
{
for (int i = 0; i < decks.size(); ++i) {
if (decks.at(i).filePath == filePath) {
return i;
}
}
return -1;
}
void VisualDeckStorageModel::startScan()
{
++scanGeneration;
beginResetModel();
decks.clear();
folderPaths.clear();
endResetModel();
if (deckPath.isEmpty()) {
return;
}
const QString currentDeckPath = deckPath;
const int generation = scanGeneration;
// The scan (directory walk + one stat per file) runs on a worker thread so that
// constructing the widget never stalls the UI thread on a large deck folder.
auto *watcher = new QFutureWatcher<DeckScanResult>(this);
connect(watcher, &QFutureWatcher<DeckScanResult>::finished, this, [this, watcher, generation] {
watcher->deleteLater();
if (generation != scanGeneration) {
return; // A newer scan started while this one was running; drop the stale result.
}
const DeckScanResult result = watcher->result();
folderPaths = result.folderPaths;
if (result.decks.isEmpty()) {
return;
}
beginInsertRows(QModelIndex(), 0, result.decks.size() - 1);
decks = result.decks;
endInsertRows();
for (int row = 0; row < decks.size(); ++row) {
beginLoad(row);
}
});
watcher->setFuture(
QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); }));
}
void VisualDeckStorageModel::beginLoad(int row)
{
if (row < 0 || row >= decks.size() || decks.at(row).loadInProgress) {
return;
}
DeckPreviewData &data = decks[row];
data.loadInProgress = true;
const QString filePath = data.filePath;
const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath);
const int generation = scanGeneration;
auto *watcher = new QFutureWatcher<std::optional<DeckLoadResult>>(this);
connect(watcher, &QFutureWatcher<std::optional<DeckLoadResult>>::finished, this,
[this, watcher, filePath, generation] {
watcher->deleteLater();
if (generation != scanGeneration) {
return; // The deck list was re-scanned while this load was running; drop the stale result.
}
const int row = rowForFilePath(filePath);
if (row == -1) {
return;
}
DeckPreviewData &data = decks[row];
data.loadInProgress = false;
std::optional<DeckLoadResult> result = watcher->result();
if (!result) {
return; // Leave the row unloaded; it stays visible but without deck data.
}
data.deck = std::move(result->deck);
data.loadSucceeded = true;
data.lastModified = result->lastModified;
recomputeDeckMetadata(data);
emit dataChanged(index(row), index(row));
emit deckLoaded(row);
});
watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional<DeckLoadResult> {
std::optional<LoadedDeck> deck = DeckLoader::loadFromFile(filePath, fmt, false);
if (!deck) {
return std::nullopt;
}
return DeckLoadResult{*deck, QFileInfo(filePath).lastModified()};
}));
}
/**
* @brief Computes the color identity of a deck in WUBRG order.
*/
static QString computeColorIdentity(const LoadedDeck &deck)
{
QStringList cardList = deck.deckList.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE});
if (cardList.isEmpty()) {
return {};
}
QSet<QChar> colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G)
for (const QString &cardName : cardList) {
CardInfoPtr currentCard = CardDatabaseManager::query()->getCardInfo(cardName);
if (currentCard) {
const QString colors = currentCard->getColors(); // Something like "WUB"
for (const QChar &color : colors) {
colorSet.insert(color);
}
}
}
// Ensure the color identity is in WUBRG order
QString colorIdentity;
const QString wubrgOrder = "WUBRG";
for (const QChar &color : wubrgOrder) {
if (colorSet.contains(color)) {
colorIdentity.append(color);
}
}
return colorIdentity;
}
/**
* @brief Recomputes all derived metadata of a row from its loaded deck.
*/
void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data)
{
const DeckList &deckList = data.deck.deckList;
data.deckName = deckList.getName();
data.displayName = !data.deckName.isEmpty() ? data.deckName : QFileInfo(data.deck.lastLoadInfo.fileName).fileName();
data.tags = deckList.getTags();
data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp());
data.bannerCard = deckList.getBannerCard();
data.colorIdentity = computeColorIdentity(data.deck);
}
void VisualDeckStorageModel::setFilePathForRow(int row, const QString &newFilePath)
{
if (row < 0 || row >= decks.size()) {
return;
}
DeckPreviewData &data = decks[row];
data.filePath = newFilePath;
data.relativeFilePath = relativeFilePathFor(newFilePath, deckPath);
data.folderPath = folderPathFor(newFilePath, deckPath);
}
bool VisualDeckStorageModel::renameDeck(int row, const QString &newName)
{
if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) {
return false;
}
DeckPreviewData &data = decks[row];
data.deck.deckList.setName(newName);
if (!DeckLoader::saveToFile(data.deck)) {
return false;
}
recomputeDeckMetadata(data);
emit dataChanged(index(row), index(row), {VisualDeckStorageRoles::DisplayNameRole});
return true;
}
bool VisualDeckStorageModel::renameFile(int row, const QString &newBaseName)
{
if (row < 0 || row >= decks.size() || newBaseName.isEmpty()) {
return false;
}
DeckPreviewData &data = decks[row];
const QFileInfo info(data.filePath);
if (newBaseName == info.baseName()) {
return false;
}
QString newFileName = newBaseName;
if (!info.suffix().isEmpty()) {
newFileName += "." + info.suffix();
}
const QString newFilePath = QFileInfo(info.dir(), newFileName).filePath();
if (!QFile::rename(info.filePath(), newFilePath)) {
return false;
}
const QString oldFilePath = data.filePath;
data.deck.lastLoadInfo.fileName = newFilePath;
setFilePathForRow(row, newFilePath);
data.lastModified = QFileInfo(newFilePath).lastModified();
emit dataChanged(index(row), index(row));
emit deckFilePathChanged(oldFilePath, newFilePath);
return true;
}
bool VisualDeckStorageModel::deleteFile(int row)
{
if (row < 0 || row >= decks.size()) {
return false;
}
const QString filePath = decks.at(row).filePath;
if (!QFile::remove(QFileInfo(filePath).filePath())) {
return false;
}
beginRemoveRows(QModelIndex(), row, row);
decks.removeAt(row);
endRemoveRows();
return true;
}
bool VisualDeckStorageModel::setTags(int row, const QStringList &tags)
{
if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) {
return false;
}
DeckPreviewData &data = decks[row];
data.deck.deckList.setTags(tags);
if (!DeckLoader::saveToFile(data.deck)) {
return false;
}
data.tags = tags;
emit dataChanged(index(row), index(row), {VisualDeckStorageRoles::TagsRole});
return true;
}
bool VisualDeckStorageModel::setBannerCard(int row, const CardRef &cardRef)
{
if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) {
return false;
}
DeckPreviewData &data = decks[row];
data.deck.deckList.setBannerCard(cardRef);
if (!DeckLoader::saveToFile(data.deck)) {
return false;
}
data.bannerCard = cardRef;
emit dataChanged(index(row), index(row),
{VisualDeckStorageRoles::BannerCardNameRole, VisualDeckStorageRoles::BannerCardProviderIdRole});
return true;
}
bool VisualDeckStorageModel::convertToCockatriceFormat(int row)
{
if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) {
return false;
}
DeckPreviewData &data = decks[row];
const QString oldFilePath = data.filePath;
if (!DeckLoader::convertToCockatriceFormat(data.deck)) {
return false;
}
setFilePathForRow(row, data.deck.lastLoadInfo.fileName);
data.lastModified = QFileInfo(data.filePath).lastModified();
recomputeDeckMetadata(data);
emit dataChanged(index(row), index(row));
if (oldFilePath != data.filePath) {
emit deckFilePathChanged(oldFilePath, data.filePath);
}
return true;
}
bool VisualDeckStorageModel::reloadIfModified(int row)
{
if (row < 0 || row >= decks.size()) {
return false;
}
DeckPreviewData &data = decks[row];
QFileInfo fileInfo(data.filePath);
const QDateTime newLastModified = fileInfo.lastModified();
if (!newLastModified.isValid() || newLastModified <= data.lastModified) {
return false;
}
std::optional<LoadedDeck> result =
DeckLoader::loadFromFile(data.filePath, DeckFileFormat::getFormatFromName(data.filePath), false);
if (!result) {
return false;
}
data.deck = *result;
data.loadSucceeded = true;
data.lastModified = fileInfo.lastModified();
recomputeDeckMetadata(data);
emit dataChanged(index(row), index(row));
emit deckLoaded(row);
return true;
}

View file

@ -0,0 +1,156 @@
/**
* @file visual_deck_storage_model.h
* @ingroup VisualDeckStorageWidgets
* @brief Source model for the Visual Deck Storage: the deck files on disk.
*
* The model owns the deck metadata (name, tags, color identity, banner card,
* modification times) and the parsed deck list, loading each deck file in the
* background. Views read through the roles or the direct accessors, and all
* mutations (rename, tags, banner card, delete, conversion) go through this
* class so the view layer never touches the filesystem directly.
*/
#ifndef VISUAL_DECK_STORAGE_MODEL_H
#define VISUAL_DECK_STORAGE_MODEL_H
#include "../../deck_loader/loaded_deck.h"
#include <QAbstractListModel>
#include <QDateTime>
#include <QList>
#include <QStringList>
#include <libcockatrice/deck_list/deck_list.h>
#include <optional>
namespace VisualDeckStorageRoles
{
/**
* @brief Custom roles exposed by the VisualDeckStorageModel.
*/
enum
{
FilePathRole = Qt::UserRole + 1, /**< Absolute file path of the deck. */
RelativeFilePathRole, /**< File path relative to the deck folder. */
FolderPathRole, /**< Directory of the deck relative to the deck folder ("" for root). */
DisplayNameRole, /**< Deck name, or the file name if the deck has no name. */
TagsRole, /**< The deck's tags. */
ColorIdentityRole, /**< The deck's color identity (WUBRG order). */
LastModifiedRole, /**< QDateTime of the deck file's last modification. */
LastLoadedRole, /**< QDateTime when the deck was last loaded from the file. */
BannerCardNameRole, /**< Name of the deck's banner card. */
BannerCardProviderIdRole /**< Provider id of the deck's banner card. */
};
} // namespace VisualDeckStorageRoles
/**
* @brief One deck file as seen by the Visual Deck Storage.
*
* The metadata is computed once when the deck loads and refreshed on reloads
* and mutations, so filters and sorts never re-read the file from disk.
*/
struct DeckPreviewData
{
QString filePath; ///< Absolute file path.
QString relativeFilePath; ///< File path relative to the deck folder.
QString folderPath; ///< Directory relative to the deck folder ("" for the deck folder itself).
QString deckName; ///< The deck name as stored in the file (may be empty).
QString displayName; ///< Deck name, or the file name if the deck has no name.
QStringList tags; ///< The deck's tags.
QString colorIdentity; ///< The deck's color identity in WUBRG order.
QDateTime lastModified; ///< File modification time at last check.
QDateTime lastLoaded; ///< When the deck was last loaded from the file.
CardRef bannerCard; ///< The deck's banner card (name + provider id).
LoadedDeck deck; ///< The parsed deck; empty until the file has been loaded.
bool loadSucceeded = false; ///< Whether the deck file finished loading successfully.
bool loadInProgress = false; ///< Whether the deck file is currently being loaded.
};
/**
* @brief The list model backing the Visual Deck Storage widget tree.
*
* Rows are in filesystem scan order; ordering and filtering are handled by
* VisualDeckStorageSortFilterProxyModel on top of this model.
*/
class VisualDeckStorageModel : public QAbstractListModel
{
Q_OBJECT
public:
explicit VisualDeckStorageModel(QObject *parent = nullptr);
/// @name Qt model overrides
///@{
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;
///@}
/**
* @brief Sets the folder to scan for deck files and starts (re)loading.
* Clears the model immediately (modelReset), then populates it asynchronously
* as the background scan discovers deck files.
*/
void setDeckPath(const QString &path);
/**
* @brief Re-scans the current deck folder, reloading every deck file.
*/
void refresh();
[[nodiscard]] QString getDeckPath() const
{
return deckPath;
}
/**
* @brief The relative paths of all subdirectories of the deck folder, one level at a time.
* Used by the view to build the folder tree. Sorted for deterministic order.
*/
[[nodiscard]] QStringList getFolderPaths() const
{
return folderPaths;
}
/// @name Data accessors
///@{
[[nodiscard]] const DeckPreviewData &dataForRow(int row) const;
[[nodiscard]] const LoadedDeck &deckForRow(int row) const;
[[nodiscard]] int rowForFilePath(const QString &filePath) const;
///@}
/// @name Mutations (persist to disk and update the row)
///@{
bool renameDeck(int row, const QString &newName);
bool renameFile(int row, const QString &newBaseName);
bool deleteFile(int row);
bool setTags(int row, const QStringList &tags);
bool setBannerCard(int row, const CardRef &cardRef);
bool convertToCockatriceFormat(int row);
bool reloadIfModified(int row);
///@}
signals:
/**
* @brief Emitted when a deck file finishes loading.
* @param row The row of the deck that finished loading.
*/
void deckLoaded(int row);
/**
* @brief Emitted when a deck's file path changes (rename file, conversion).
* @param oldFilePath The previous file path.
* @param newFilePath The new file path.
*/
void deckFilePathChanged(const QString &oldFilePath, const QString &newFilePath);
private:
void startScan();
void beginLoad(int row);
static void recomputeDeckMetadata(DeckPreviewData &data);
void setFilePathForRow(int row, const QString &newFilePath);
QString deckPath;
QList<DeckPreviewData> decks;
QStringList folderPaths; ///< All subdirectories of the deck folder, sorted.
int scanGeneration = 0; ///< Bumped on every scan so stale results are ignored.
};
#endif // VISUAL_DECK_STORAGE_MODEL_H

View file

@ -0,0 +1,275 @@
#include "visual_deck_storage_sort_filter_proxy_model.h"
#include "../../filters/deck_filter_string.h"
#include <QFileInfo>
#include <algorithm>
VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QObject *parent)
: QSortFilterProxyModel(parent)
{
setDynamicSortFilter(false);
}
void VisualDeckStorageSortFilterProxyModel::setSourceModel(QAbstractItemModel *model)
{
if (QAbstractItemModel *oldModel = sourceModel()) {
disconnect(oldModel, &QAbstractItemModel::modelReset, this,
&VisualDeckStorageSortFilterProxyModel::resizeMatchLists);
disconnect(oldModel, &QAbstractItemModel::rowsInserted, this,
&VisualDeckStorageSortFilterProxyModel::resizeMatchLists);
disconnect(oldModel, &QAbstractItemModel::rowsRemoved, this,
&VisualDeckStorageSortFilterProxyModel::resizeMatchLists);
}
QSortFilterProxyModel::setSourceModel(model);
if (model) {
connect(model, &QAbstractItemModel::modelReset, this, &VisualDeckStorageSortFilterProxyModel::resizeMatchLists);
connect(model, &QAbstractItemModel::rowsInserted, this,
&VisualDeckStorageSortFilterProxyModel::resizeMatchLists);
connect(model, &QAbstractItemModel::rowsRemoved, this,
&VisualDeckStorageSortFilterProxyModel::resizeMatchLists);
}
resizeMatchLists();
}
void VisualDeckStorageSortFilterProxyModel::setSearchText(const QString &text)
{
if (searchText == text) {
return;
}
searchText = text;
updateSearchMatches();
invalidate();
}
void VisualDeckStorageSortFilterProxyModel::setTagFilter(const QSet<QString> &newSelectedTags,
const QSet<QString> &newExcludedTags)
{
if (selectedTags == newSelectedTags && excludedTags == newExcludedTags) {
return;
}
selectedTags = newSelectedTags;
excludedTags = newExcludedTags;
updateTagMatches();
invalidate();
}
void VisualDeckStorageSortFilterProxyModel::setColorFilter(FilterMode mode, const QSet<QChar> &colors)
{
if (colorFilterMode == mode && activeColors == colors) {
return;
}
colorFilterMode = mode;
activeColors = colors;
updateColorMatches();
invalidate();
}
void VisualDeckStorageSortFilterProxyModel::setSortOrder(SortOrder order)
{
// No equality guard: the initial reapply (with the default order) must still
// trigger sort(0), since without a sort the proxy would show scan order.
sortOrder = order;
sort(0);
}
void VisualDeckStorageSortFilterProxyModel::reapplyFilters()
{
const QList<bool> oldSearchMatches = searchMatches;
const QList<bool> oldTagMatches = tagMatches;
const QList<bool> oldColorMatches = colorMatches;
updateSearchMatches();
updateTagMatches();
updateColorMatches();
if (searchMatches != oldSearchMatches || tagMatches != oldTagMatches || colorMatches != oldColorMatches) {
invalidate();
}
if (sortOrder == ByName || sortOrder == ByLastLoaded) {
// These orders depend on data that only becomes available when a deck finishes loading.
sort(0);
}
}
void VisualDeckStorageSortFilterProxyModel::resort()
{
sort(0);
}
bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
if (sourceParent.isValid()) {
return true;
}
// If the match lists aren't sized to the current model yet, don't hide anything.
if (sourceRow < 0 || sourceRow >= searchMatches.size() || sourceRow >= tagMatches.size() ||
sourceRow >= colorMatches.size()) {
return true;
}
return searchMatches.at(sourceRow) && tagMatches.at(sourceRow) && colorMatches.at(sourceRow);
}
bool VisualDeckStorageSortFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
{
const auto *source = deckSourceModel();
if (!source) {
return false;
}
const DeckPreviewData &leftData = source->dataForRow(left.row());
const DeckPreviewData &rightData = source->dataForRow(right.row());
switch (sortOrder) {
case ByName:
return leftData.deckName < rightData.deckName;
case Alphabetical:
return QString::localeAwareCompare(QFileInfo(leftData.filePath).fileName(),
QFileInfo(rightData.filePath).fileName()) < 0;
case ByLastModified:
return leftData.lastModified > rightData.lastModified;
case ByLastLoaded:
return leftData.lastLoaded > rightData.lastLoaded;
}
return false;
}
void VisualDeckStorageSortFilterProxyModel::resizeMatchLists()
{
const int count = sourceModel() ? sourceModel()->rowCount() : 0;
searchMatches.resize(count);
searchMatches.fill(true);
tagMatches.resize(count);
tagMatches.fill(true);
colorMatches.resize(count);
colorMatches.fill(true);
}
void VisualDeckStorageSortFilterProxyModel::updateSearchMatches()
{
const auto *source = deckSourceModel();
if (!source) {
searchMatches.clear();
return;
}
const int count = source->rowCount();
searchMatches.resize(count);
if (searchText.isEmpty()) {
searchMatches.fill(true);
return;
}
DeckFilterString filterString(searchText);
for (int row = 0; row < count; ++row) {
const DeckPreviewData &data = source->dataForRow(row);
// isEmpty() is intentional: if a deck fails to load, loadInProgress becomes false
// but the deck remains empty. Using loadInProgress alone would pass failed decks
// to DeckFilterString::check, which requires a non-empty deck.
if (data.deck.isEmpty()) {
searchMatches[row] = true;
continue;
}
DeckSearchData searchData{
.deck = &data.deck,
.filePath = data.filePath,
.displayName = data.displayName,
.relativeFilePath = data.relativeFilePath,
};
searchMatches[row] = filterString.check(searchData);
}
}
void VisualDeckStorageSortFilterProxyModel::updateTagMatches()
{
const auto *source = deckSourceModel();
if (!source) {
tagMatches.clear();
return;
}
const int count = source->rowCount();
tagMatches.resize(count);
if (selectedTags.isEmpty() && excludedTags.isEmpty()) {
tagMatches.fill(true);
return;
}
for (int row = 0; row < count; ++row) {
const QStringList deckTags = source->dataForRow(row).tags;
const bool hasAllSelected = std::all_of(selectedTags.begin(), selectedTags.end(),
[&deckTags](const QString &tag) { return deckTags.contains(tag); });
const bool hasAnyExcluded = std::any_of(excludedTags.begin(), excludedTags.end(),
[&deckTags](const QString &tag) { return deckTags.contains(tag); });
tagMatches[row] = hasAllSelected && !hasAnyExcluded;
}
}
void VisualDeckStorageSortFilterProxyModel::updateColorMatches()
{
const auto *source = deckSourceModel();
if (!source) {
colorMatches.clear();
return;
}
const int count = source->rowCount();
colorMatches.resize(count);
if (activeColors.isEmpty()) {
colorMatches.fill(true);
return;
}
for (int row = 0; row < count; ++row) {
const QString colorIdentity = source->dataForRow(row).colorIdentity;
bool matches = true;
switch (colorFilterMode) {
case ExactMatch: {
QSet<QChar> activeColorSet;
for (const QChar &color : activeColors) {
activeColorSet.insert(color.toUpper());
}
QSet<QChar> colorIdentitySet;
for (const QChar &color : colorIdentity) {
colorIdentitySet.insert(color.toUpper());
}
matches = activeColorSet == colorIdentitySet;
break;
}
case Includes:
matches = std::all_of(activeColors.begin(), activeColors.end(),
[&colorIdentity](const QChar &color) { return colorIdentity.contains(color); });
break;
case Excludes:
matches = std::none_of(activeColors.begin(), activeColors.end(),
[&colorIdentity](const QChar &color) { return colorIdentity.contains(color); });
break;
}
colorMatches[row] = matches;
}
}
const VisualDeckStorageModel *VisualDeckStorageSortFilterProxyModel::deckSourceModel() const
{
return qobject_cast<const VisualDeckStorageModel *>(sourceModel());
}

View file

@ -0,0 +1,98 @@
/**
* @file visual_deck_storage_sort_filter_proxy_model.h
* @ingroup VisualDeckStorageWidgets
* @brief Sorting and filtering proxy on top of VisualDeckStorageModel.
*
* Owns all search / tag / color filter state and the sort order. Filtering is
* evaluated against the model's data (never against widgets), so it can run
* before any view exists and re-evaluate whenever deck data finishes loading.
*/
#ifndef VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H
#define VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H
#include "visual_deck_storage_model.h"
#include <QSet>
#include <QSortFilterProxyModel>
#include <QString>
class VisualDeckStorageSortFilterProxyModel : public QSortFilterProxyModel
{
Q_OBJECT
public:
/**
* @brief The order in which decks are sorted. Values must match the
* entries of the sort widget's combo box and the stored settings value.
*/
enum SortOrder
{
ByName,
Alphabetical,
ByLastModified,
ByLastLoaded,
};
Q_ENUM(SortOrder)
/**
* @brief How the color identity filter is applied.
*/
enum FilterMode
{
ExactMatch,
Includes,
Excludes
};
Q_ENUM(FilterMode)
explicit VisualDeckStorageSortFilterProxyModel(QObject *parent = nullptr);
void setSourceModel(QAbstractItemModel *model) override;
/// @name Filter input setters (each re-evaluates the affected matches)
///@{
void setSearchText(const QString &text);
void setTagFilter(const QSet<QString> &newSelectedTags, const QSet<QString> &newExcludedTags);
void setColorFilter(FilterMode mode, const QSet<QChar> &colors);
///@}
/**
* @brief Sets the sort order and applies it immediately.
*/
void setSortOrder(SortOrder order);
/**
* @brief Re-evaluates all matches against the current model data and
* re-applies filtering and sorting. Called after deck data changes.
*/
void reapplyFilters();
/**
* @brief Re-applies the current sort order without touching the filters.
*/
void resort();
protected:
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
bool lessThan(const QModelIndex &left, const QModelIndex &right) const override;
private:
void resizeMatchLists();
void updateSearchMatches();
void updateTagMatches();
void updateColorMatches();
[[nodiscard]] const VisualDeckStorageModel *deckSourceModel() const;
QString searchText;
QSet<QString> selectedTags;
QSet<QString> excludedTags;
FilterMode colorFilterMode = ExactMatch;
QSet<QChar> activeColors;
SortOrder sortOrder = Alphabetical;
QList<bool> searchMatches; ///< Per-row search match, sized like the source model.
QList<bool> tagMatches; ///< Per-row tag match.
QList<bool> colorMatches; ///< Per-row color identity match.
};
#endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H

View file

@ -11,6 +11,7 @@ set(HEADERS
game/server_cardzone.h
game/server_counter.h
game/game_config.h
game/server_deck_validation_strategy.h
game/server_game.h
game/server_player.h
game/server_spectator.h

View file

@ -0,0 +1,45 @@
#ifndef SERVER_DECK_VALIDATION_STRATEGY_H
#define SERVER_DECK_VALIDATION_STRATEGY_H
#include <libcockatrice/protocol/pb/response.pb.h>
class DeckList;
class Server_Game;
class Server_Player;
class ResponseContainer;
/**
* @brief Strategy for validating a player's deck before it is loaded into a game.
*
* Subclasses decide whether a deck may be accepted; the default implementation
* accepts every deck.
*/
class Server_DeckValidationStrategy
{
public:
virtual ~Server_DeckValidationStrategy() = default;
/**
* @brief Validate @p deck for @p player in @p game.
*
* @p rc is an out parameter used to attach the response details for a rejected
* deck (e.g. an error response extension via ResponseContainer::setResponseExtension).
* @return Response::RespOk when the deck is accepted, an error code otherwise.
*/
virtual Response::ResponseCode
validate(Server_Game *game, Server_Player *player, DeckList *deck, ResponseContainer &rc) = 0;
};
/**
* @brief Default deck validation strategy that accepts every deck.
*/
class Server_DefaultDeckValidationStrategy : public Server_DeckValidationStrategy
{
public:
Response::ResponseCode validate(Server_Game *, Server_Player *, DeckList *, ResponseContainer &) override
{
return Response::RespOk;
}
};
#endif

View file

@ -62,7 +62,8 @@ Server_Game::Server_Game(const GameConfig &config, Server_Room *_room)
spectatorsCanTalk(config.spectatorsCanTalk), spectatorsSeeEverything(config.spectatorsSeeEverything),
startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad),
inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false),
turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), gameMutex()
turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr),
deckValidationStrategy(new Server_DefaultDeckValidationStrategy), gameMutex()
{
currentReplay = new GameReplay;
currentReplay->set_replay_id(room->getServer()->getDatabaseInterface()->getNextReplayId());
@ -886,3 +887,8 @@ void Server_Game::returnCardsFromPlayer(GameEventStorage &ges, Server_AbstractPl
}
}
}
void Server_Game::setDeckValidationStrategy(Server_DeckValidationStrategy *strategy)
{
deckValidationStrategy.reset(strategy);
}

View file

@ -22,11 +22,13 @@
#include "../server_response_containers.h"
#include "game_config.h"
#include "server_deck_validation_strategy.h"
#include <QDateTime>
#include <QMap>
#include <QMutex>
#include <QObject>
#include <QScopedPointer>
#include <QSet>
#include <QStringList>
#include <libcockatrice/protocol/pb/event_leave.pb.h>
@ -79,6 +81,8 @@ private:
QList<GameReplay *> replayList;
GameReplay *currentReplay;
QScopedPointer<Server_DeckValidationStrategy> deckValidationStrategy;
void createGameStateChangedEvent(Event_GameStateChanged *event,
Server_AbstractParticipant *recipient,
bool omniscient,
@ -208,6 +212,14 @@ public:
GameEventStorageItem::SendToOthers,
int privatePlayerId = -1);
void returnCardsFromPlayer(GameEventStorage &ges, Server_AbstractPlayer *player);
/** @brief Get the current deck validation strategy (non-owning). */
Server_DeckValidationStrategy *getDeckValidationStrategy() const
{
return deckValidationStrategy.data();
}
/** @brief Replace the deck validation strategy; takes ownership of @p strategy. */
void setDeckValidationStrategy(Server_DeckValidationStrategy *strategy);
};
#endif