From 776f917ffc9e49048f81313b4f7e544a5bd81bc2 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:37:51 +0200 Subject: [PATCH 1/4] [Client] Confirm before joining a game opened from a game link (#7137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 --- .../src/interface/intents/url_parser.cpp | 47 +++++++++++++++++++ cockatrice/src/interface/intents/url_parser.h | 3 ++ 2 files changed, 50 insertions(+) diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 8b5309603..509390611 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -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 #include #include +#include #include 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); +} diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h index bac0e3d25..6d705e013 100644 --- a/cockatrice/src/interface/intents/url_parser.h +++ b/cockatrice/src/interface/intents/url_parser.h @@ -4,6 +4,7 @@ #include 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; }; From 765ebf8fb1be894194343ae2f257a9ac471bd55b Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:49:19 +0200 Subject: [PATCH 2/4] [UserList] Context menu invite (#7138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 --- .../widgets/server/user/user_context_menu.cpp | 67 ++++++++++++++++++- .../widgets/server/user/user_context_menu.h | 30 ++++++++- .../widgets/server/user/user_list_widget.cpp | 5 ++ .../widgets/server/user/user_list_widget.h | 3 + .../interface/widgets/tabs/tab_message.cpp | 29 +++++--- .../src/interface/widgets/tabs/tab_message.h | 3 + .../src/interface/widgets/tabs/tab_room.cpp | 5 ++ .../interface/widgets/tabs/tab_supervisor.cpp | 48 +++++++++++++ .../interface/widgets/tabs/tab_supervisor.h | 3 + 9 files changed, 179 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 5c4a88974..372dbfc19 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -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(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 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 UserContextMenu::inviteOptionsForUser(const QString &userName) const +{ + if (!gameInviteLinkProvider) { + return {}; + } + const QList options = gameInviteLinkProvider(); + QList 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 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(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(parent()), diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index 00fdc51fe..70bbff977 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -7,9 +7,12 @@ #ifndef USER_CONTEXT_MENU_H #define USER_CONTEXT_MENU_H -#include -#include +#include "../../interface/widgets/server/game_link.h" +#include +#include +#include +#include class AbstractGame; class UserListProxy; class AbstractClient; @@ -43,6 +46,7 @@ private: QAction *aPromoteToJudge, *aDemoteFromJudge; QAction *aWarnUser, *aWarnHistory; QAction *aGetAdminNotes; + std::function()> gameInviteLinkProvider; signals: void openMessageDialog(const QString &userName, bool focus); private slots: @@ -80,9 +84,28 @@ public: return userListProxy; } + void setGameInviteLinkProvider(std::function()> 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 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 diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index be52b9871..b63457169 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1838,3 +1838,8 @@ void UserListWidget::finishSectionedMutation() applyFilter(); userTree->viewport()->update(); } + +void UserListWidget::setGameInviteLinkProvider(std::function()> provider) +{ + userContextMenu->setGameInviteLinkProvider(std::move(provider)); +} diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index e048c7fb7..d97843264 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -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 #include #include +#include #include #include @@ -271,6 +273,7 @@ public: } void showContextMenu(const QPoint &pos, const QModelIndex &index); void sortItems(); + void setGameInviteLinkProvider(std::function()> provider); protected: void hideEvent(QHideEvent *e) override; diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp index 9eccea7a2..d482d3dd7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -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) diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.h b/cockatrice/src/interface/widgets/tabs/tab_message.h index f7d15b4f6..e9b987ce2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.h +++ b/cockatrice/src/interface/widgets/tabs/tab_message.h @@ -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); diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 508d5a048..6245b5301 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -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 #include #include +#include #include #include #include @@ -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); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 1ab812c54..77b93802a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -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 TabSupervisor::getGameInviteLinksForRoom(int roomId) const +{ + QList 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 diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 5ac3eb365..81ad22f54 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -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 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); From a8bacc529668a26054db3e5f2c7522416e52decc Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:20:26 +0200 Subject: [PATCH 3/4] [VDS] Add async-scanned model and sort/filter proxy (#7105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 --- cockatrice/CMakeLists.txt | 2 + .../visual_deck_storage_model.cpp | 503 ++++++++++++++++++ .../visual_deck_storage_model.h | 156 ++++++ ...l_deck_storage_sort_filter_proxy_model.cpp | 275 ++++++++++ ...ual_deck_storage_sort_filter_proxy_model.h | 98 ++++ 5 files changed, 1034 insertions(+) create mode 100644 cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp create mode 100644 cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h create mode 100644 cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp create mode 100644 cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 9ea463eef..1c2755f71 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -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 diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp new file mode 100644 index 000000000..bf2c49604 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp @@ -0,0 +1,503 @@ +#include "visual_deck_storage_model.h" + +#include "../../deck_loader/deck_loader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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(this); + connect(watcher, &QFutureWatcher::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>(this); + connect(watcher, &QFutureWatcher>::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 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 { + std::optional 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 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 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; +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h new file mode 100644 index 000000000..a44e7412d --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h @@ -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 +#include +#include +#include +#include +#include + +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 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 diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp new file mode 100644 index 000000000..8968f6cb3 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp @@ -0,0 +1,275 @@ +#include "visual_deck_storage_sort_filter_proxy_model.h" + +#include "../../filters/deck_filter_string.h" + +#include +#include + +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 &newSelectedTags, + const QSet &newExcludedTags) +{ + if (selectedTags == newSelectedTags && excludedTags == newExcludedTags) { + return; + } + + selectedTags = newSelectedTags; + excludedTags = newExcludedTags; + updateTagMatches(); + invalidate(); +} + +void VisualDeckStorageSortFilterProxyModel::setColorFilter(FilterMode mode, const QSet &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 oldSearchMatches = searchMatches; + const QList oldTagMatches = tagMatches; + const QList 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 activeColorSet; + for (const QChar &color : activeColors) { + activeColorSet.insert(color.toUpper()); + } + + QSet 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(sourceModel()); +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h new file mode 100644 index 000000000..d2842a02f --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h @@ -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 +#include +#include + +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 &newSelectedTags, const QSet &newExcludedTags); + void setColorFilter(FilterMode mode, const QSet &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 selectedTags; + QSet excludedTags; + FilterMode colorFilterMode = ExactMatch; + QSet activeColors; + SortOrder sortOrder = Alphabetical; + + QList searchMatches; ///< Per-row search match, sized like the source model. + QList tagMatches; ///< Per-row tag match. + QList colorMatches; ///< Per-row color identity match. +}; + +#endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H From 08d6b51db98fdb435db87e05b7ad0c39abb127bf Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:21:25 +0200 Subject: [PATCH 4/4] [Server] Add deck validation strategy interface (#7129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas Brübach --- .../network/server/remote/CMakeLists.txt | 1 + .../game/server_deck_validation_strategy.h | 45 +++++++++++++++++++ .../server/remote/game/server_game.cpp | 8 +++- .../network/server/remote/game/server_game.h | 12 +++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 80a80e1ae..fb4fd3155 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -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 diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h new file mode 100644 index 000000000..8298214b4 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h @@ -0,0 +1,45 @@ +#ifndef SERVER_DECK_VALIDATION_STRATEGY_H +#define SERVER_DECK_VALIDATION_STRATEGY_H + +#include + +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 diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 60d11ead1..069a10463 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -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); +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index 60b5398f2..da316975d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -22,11 +22,13 @@ #include "../server_response_containers.h" #include "game_config.h" +#include "server_deck_validation_strategy.h" #include #include #include #include +#include #include #include #include @@ -79,6 +81,8 @@ private: QList replayList; GameReplay *currentReplay; + QScopedPointer 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