mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-29 09:22:18 -07:00
Merge branch 'master' into pr/7035
This commit is contained in:
commit
0b92762649
33 changed files with 3474 additions and 336 deletions
|
|
@ -36,6 +36,7 @@ set(cockatrice_SOURCES
|
||||||
src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp
|
src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp
|
||||||
src/interface/widgets/dialogs/dlg_forgot_password_request.cpp
|
src/interface/widgets/dialogs/dlg_forgot_password_request.cpp
|
||||||
src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp
|
src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp
|
||||||
|
src/interface/widgets/dialogs/dlg_invite_to_game.cpp
|
||||||
src/interface/widgets/dialogs/dlg_load_deck.cpp
|
src/interface/widgets/dialogs/dlg_load_deck.cpp
|
||||||
src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp
|
src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp
|
||||||
src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp
|
src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp
|
||||||
|
|
@ -246,6 +247,7 @@ set(cockatrice_SOURCES
|
||||||
src/interface/widgets/replay/replay_widget.cpp
|
src/interface/widgets/replay/replay_widget.cpp
|
||||||
src/interface/widgets/server/chat_view/chat_view.cpp
|
src/interface/widgets/server/chat_view/chat_view.cpp
|
||||||
src/interface/widgets/server/game_filter_configs.cpp
|
src/interface/widgets/server/game_filter_configs.cpp
|
||||||
|
src/interface/widgets/server/game_link.cpp
|
||||||
src/interface/widgets/server/game_selector.cpp
|
src/interface/widgets/server/game_selector.cpp
|
||||||
src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp
|
src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp
|
||||||
src/interface/widgets/server/games_model.cpp
|
src/interface/widgets/server/games_model.cpp
|
||||||
|
|
@ -300,8 +302,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_tag_item_widget.cpp
|
||||||
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_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_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_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_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_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_tag_filter_widget.cpp
|
||||||
src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
|
src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
#include "url_parser.h"
|
#include "url_parser.h"
|
||||||
|
|
||||||
|
#include "../widgets/tabs/tab_room.h"
|
||||||
|
#include "../widgets/tabs/tab_supervisor.h"
|
||||||
#include "../window_main.h"
|
#include "../window_main.h"
|
||||||
#include "contexts/context_join_game.h"
|
#include "contexts/context_join_game.h"
|
||||||
#include "intent_join_server_game.h"
|
#include "intent_join_server_game.h"
|
||||||
|
|
@ -9,6 +11,7 @@
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
#include <QUrlQuery>
|
#include <QUrlQuery>
|
||||||
|
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow)
|
IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow)
|
||||||
|
|
@ -71,6 +74,15 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
|
||||||
return;
|
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
|
// The join game intent owns the context and the credential lookup; once the
|
||||||
// chain finishes (or fails) it deletes the whole tree.
|
// chain finishes (or fails) it deletes the whole tree.
|
||||||
ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
|
ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
|
||||||
|
|
@ -87,3 +99,38 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
|
||||||
|
|
||||||
getLoginCredentialsIntent->execute();
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
#include <QUrlQuery>
|
#include <QUrlQuery>
|
||||||
|
|
||||||
class MainWindow;
|
class MainWindow;
|
||||||
|
struct ContextJoinGame;
|
||||||
class IntentUrlParser : public QObject
|
class IntentUrlParser : public QObject
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
@ -14,6 +15,8 @@ public:
|
||||||
void handleJoinGame(const QUrlQuery &query);
|
void handleJoinGame(const QUrlQuery &query);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription);
|
||||||
|
|
||||||
MainWindow *mainWindow;
|
MainWindow *mainWindow;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
112
cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp
Normal file
112
cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
#include "dlg_invite_to_game.h"
|
||||||
|
|
||||||
|
#include "../server/user/user_list_manager.h"
|
||||||
|
#include "../server/user/user_list_widget.h"
|
||||||
|
#include "../tabs/tab_supervisor.h"
|
||||||
|
|
||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QScreen>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QUrlQuery>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
DlgInviteToGame::DlgInviteToGame(TabSupervisor *_tabSupervisor,
|
||||||
|
const QString &_inviteUrl,
|
||||||
|
bool _onlyBuddies,
|
||||||
|
const QStringList &_excludeUserNames,
|
||||||
|
QWidget *parent)
|
||||||
|
: QDialog(parent), tabSupervisor(_tabSupervisor), inviteUrl(_inviteUrl), onlyBuddies(_onlyBuddies),
|
||||||
|
excludeUserNames(_excludeUserNames)
|
||||||
|
{
|
||||||
|
setModal(true);
|
||||||
|
|
||||||
|
searchEdit = new QLineEdit(this);
|
||||||
|
searchEdit->setClearButtonEnabled(true);
|
||||||
|
connect(searchEdit, &QLineEdit::textChanged, this, &DlgInviteToGame::searchTextChanged);
|
||||||
|
|
||||||
|
// The embedded list is the real room user list without the hover popup:
|
||||||
|
// same manager, same delegate/painter, same sections, live via manager
|
||||||
|
// signals while the modal loop runs.
|
||||||
|
UserListManager *manager = tabSupervisor->getUserListManager();
|
||||||
|
userList = new UserListWidget(tabSupervisor, tabSupervisor->getClient(), UserListWidget::RoomList, this,
|
||||||
|
/*hasUserInfoPopup=*/false);
|
||||||
|
userList->setUserFilter([this, manager](const QString &name, bool online) {
|
||||||
|
return !excludeUserNames.contains(name) && online && !manager->isUserIgnored(name);
|
||||||
|
});
|
||||||
|
if (onlyBuddies) {
|
||||||
|
userList->setSectioned({UserListWidget::Section::Buddy});
|
||||||
|
} else {
|
||||||
|
userList->setSectioned({UserListWidget::Section::Buddy, UserListWidget::Section::Online});
|
||||||
|
}
|
||||||
|
userList->bind(manager);
|
||||||
|
userList->rebuild();
|
||||||
|
|
||||||
|
connect(userList, &UserListWidget::userActivated, this, &DlgInviteToGame::inviteCurrentUser);
|
||||||
|
connect(userList, &UserListWidget::currentUserChanged, this, [this](const QString &userName) {
|
||||||
|
currentUserName = userName;
|
||||||
|
inviteButton->setEnabled(!userName.isEmpty());
|
||||||
|
});
|
||||||
|
|
||||||
|
inviteButton = new QPushButton(this);
|
||||||
|
inviteButton->setEnabled(false);
|
||||||
|
inviteButton->setDefault(true);
|
||||||
|
connect(inviteButton, &QPushButton::clicked, this, [this] { inviteCurrentUser(currentUserName); });
|
||||||
|
|
||||||
|
cancelButton = new QPushButton(this);
|
||||||
|
connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject);
|
||||||
|
|
||||||
|
auto *buttonRow = new QHBoxLayout;
|
||||||
|
buttonRow->addStretch();
|
||||||
|
buttonRow->addWidget(inviteButton);
|
||||||
|
buttonRow->addWidget(cancelButton);
|
||||||
|
|
||||||
|
auto *layout = new QVBoxLayout(this);
|
||||||
|
layout->addWidget(searchEdit);
|
||||||
|
layout->addWidget(userList, 1);
|
||||||
|
layout->addLayout(buttonRow);
|
||||||
|
|
||||||
|
retranslateUi();
|
||||||
|
|
||||||
|
// Default to a comfortably tall dialog so the list has room to breathe,
|
||||||
|
// capped by the available screen. No minimum is enforced: small screens
|
||||||
|
// and manual resizing can go shorter than this.
|
||||||
|
const QRect availableScreen = QGuiApplication::primaryScreen()->availableGeometry();
|
||||||
|
resize(sizeHint().width(), qMin(sizeHint().height() * 3, availableScreen.height() * 4 / 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
void DlgInviteToGame::searchTextChanged(const QString &text)
|
||||||
|
{
|
||||||
|
userList->setFilterText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DlgInviteToGame::inviteCurrentUser(const QString &userName)
|
||||||
|
{
|
||||||
|
if (userName.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The invite link carries the game's id and, when the game has one, its
|
||||||
|
// description (makeGameJoinLink embeds both). Read them back so the prefix
|
||||||
|
// names the game by description first, then its id — identical to the
|
||||||
|
// context-menu invite so recipients see one consistent message style.
|
||||||
|
const QUrl inviteUrlObj(inviteUrl);
|
||||||
|
const QUrlQuery inviteQuery(inviteUrlObj);
|
||||||
|
const int gameId = inviteQuery.queryItemValue("gameid").toInt();
|
||||||
|
const QString gameDescription = inviteQuery.queryItemValue("game");
|
||||||
|
const QString prefix = gameDescription.isEmpty()
|
||||||
|
? tr("Join my game (#%1):").arg(gameId)
|
||||||
|
: tr("Join my game \"%1\" (#%2):").arg(gameDescription).arg(gameId);
|
||||||
|
tabSupervisor->sendInviteToUser(userName, prefix + " " + inviteUrl);
|
||||||
|
accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DlgInviteToGame::retranslateUi()
|
||||||
|
{
|
||||||
|
setWindowTitle(tr("Invite to Game"));
|
||||||
|
searchEdit->setPlaceholderText(tr("Search users..."));
|
||||||
|
inviteButton->setText(tr("Invite"));
|
||||||
|
cancelButton->setText(tr("Cancel"));
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
/**
|
||||||
|
* @file dlg_invite_to_game.h
|
||||||
|
* @ingroup RoomDialogs
|
||||||
|
*/
|
||||||
|
//! \todo Document this file.
|
||||||
|
|
||||||
|
#ifndef DLG_INVITE_TO_GAME_H
|
||||||
|
#define DLG_INVITE_TO_GAME_H
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
class QLineEdit;
|
||||||
|
class QPushButton;
|
||||||
|
class TabSupervisor;
|
||||||
|
class UserListWidget;
|
||||||
|
|
||||||
|
class DlgInviteToGame : public QDialog
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
DlgInviteToGame(TabSupervisor *_tabSupervisor,
|
||||||
|
const QString &_inviteUrl,
|
||||||
|
bool _onlyBuddies,
|
||||||
|
const QStringList &_excludeUserNames,
|
||||||
|
QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void searchTextChanged(const QString &text);
|
||||||
|
void inviteCurrentUser(const QString &userName);
|
||||||
|
|
||||||
|
private:
|
||||||
|
TabSupervisor *tabSupervisor;
|
||||||
|
QString inviteUrl;
|
||||||
|
bool onlyBuddies;
|
||||||
|
QStringList excludeUserNames;
|
||||||
|
QString currentUserName;
|
||||||
|
QLineEdit *searchEdit;
|
||||||
|
UserListWidget *userList;
|
||||||
|
QPushButton *inviteButton;
|
||||||
|
QPushButton *cancelButton;
|
||||||
|
|
||||||
|
void retranslateUi();
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
#include <QScrollBar>
|
#include <QScrollBar>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QUrlQuery>
|
||||||
#include <libcockatrice/card/database/card_database_manager.h>
|
#include <libcockatrice/card/database/card_database_manager.h>
|
||||||
#include <libcockatrice/network/server/remote/user_level.h>
|
#include <libcockatrice/network/server/remote/user_level.h>
|
||||||
#include <libcockatrice/settings/chat_settings.h>
|
#include <libcockatrice/settings/chat_settings.h>
|
||||||
|
|
@ -49,7 +51,7 @@ ChatView::ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _sho
|
||||||
|
|
||||||
viewport()->setCursor(Qt::IBeamCursor);
|
viewport()->setCursor(Qt::IBeamCursor);
|
||||||
setReadOnly(true);
|
setReadOnly(true);
|
||||||
setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse);
|
setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
|
||||||
setOpenLinks(false);
|
setOpenLinks(false);
|
||||||
connect(this, &ChatView::anchorClicked, this, &ChatView::openLink);
|
connect(this, &ChatView::anchorClicked, this, &ChatView::openLink);
|
||||||
|
|
||||||
|
|
@ -219,6 +221,46 @@ void ChatView::appendUrlTag(QTextCursor &cursor, QString url)
|
||||||
cursor.setCharFormat(oldFormat);
|
cursor.setCharFormat(oldFormat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ChatView::appendGameLinkTag(QTextCursor &cursor, const QString &url)
|
||||||
|
{
|
||||||
|
const QUrl gameUrl(url);
|
||||||
|
const QUrlQuery query(gameUrl);
|
||||||
|
const QString hostname = query.queryItemValue("hostname");
|
||||||
|
// FullyDecoded undoes every %XX escape, so a description that itself
|
||||||
|
// contains "%" cannot end up displayed as "%25" in the label.
|
||||||
|
const QString description = query.queryItemValue("game", QUrl::FullyDecoded);
|
||||||
|
const int gameId = query.queryItemValue("gameid").toInt();
|
||||||
|
|
||||||
|
QString label;
|
||||||
|
if (gameId > 0 && !hostname.isEmpty()) {
|
||||||
|
// Links built before the description was embedded stay readable: the
|
||||||
|
// id + server fallback below is identical to the old anchor text.
|
||||||
|
if (!description.isEmpty()) {
|
||||||
|
// Multi-arg .arg() replaces all placeholders in a single pass, so a
|
||||||
|
// description containing "%…" cannot corrupt later placeholders.
|
||||||
|
label = tr("Join game \"%1\" (#%2) on %3").arg(description, QString::number(gameId), hostname);
|
||||||
|
} else {
|
||||||
|
label = tr("Join game #%1 on %2").arg(QString::number(gameId), hostname);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
label = tr("Join game");
|
||||||
|
}
|
||||||
|
|
||||||
|
QTextCharFormat oldFormat = cursor.charFormat();
|
||||||
|
QTextCharFormat gameLinkFormat = oldFormat;
|
||||||
|
gameLinkFormat.setForeground(linkColor);
|
||||||
|
gameLinkFormat.setFontWeight(QFont::Bold);
|
||||||
|
gameLinkFormat.setAnchor(true);
|
||||||
|
gameLinkFormat.setAnchorHref(url);
|
||||||
|
QColor background = palette().highlight().color();
|
||||||
|
background.setAlpha(40);
|
||||||
|
gameLinkFormat.setBackground(background);
|
||||||
|
|
||||||
|
cursor.setCharFormat(gameLinkFormat);
|
||||||
|
cursor.insertText(label);
|
||||||
|
cursor.setCharFormat(oldFormat);
|
||||||
|
}
|
||||||
|
|
||||||
void ChatView::appendMessage(QString message,
|
void ChatView::appendMessage(QString message,
|
||||||
RoomMessageTypeFlags messageType,
|
RoomMessageTypeFlags messageType,
|
||||||
const ServerInfo_User &userInfo,
|
const ServerInfo_User &userInfo,
|
||||||
|
|
@ -503,6 +545,17 @@ void ChatView::checkWord(QTextCursor &cursor, QString &message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fullWordUpToSpaceOrEnd.startsWith("cockatrice://", Qt::CaseInsensitive)) {
|
||||||
|
// Only links to a game (cockatrice://joingame) become invite buttons;
|
||||||
|
// any other cockatrice:// scheme falls through to plain text below.
|
||||||
|
const QUrl gameLink(fullWordUpToSpaceOrEnd);
|
||||||
|
if (gameLink.host().compare("joingame", Qt::CaseInsensitive) == 0) {
|
||||||
|
appendGameLinkTag(cursor, fullWordUpToSpaceOrEnd);
|
||||||
|
cursor.insertText(rest, defaultFormat);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// check word mentions
|
// check word mentions
|
||||||
for (const QString &word : highlightedWords) {
|
for (const QString &word : highlightedWords) {
|
||||||
if (fullWordUpToSpaceOrEnd.compare(word, Qt::CaseInsensitive) == 0) {
|
if (fullWordUpToSpaceOrEnd.compare(word, Qt::CaseInsensitive) == 0) {
|
||||||
|
|
@ -724,6 +777,11 @@ void ChatView::mouseReleaseEvent(QMouseEvent *event)
|
||||||
|
|
||||||
void ChatView::openLink(const QUrl &link)
|
void ChatView::openLink(const QUrl &link)
|
||||||
{
|
{
|
||||||
|
if (link.scheme() == "cockatrice") {
|
||||||
|
emit cockatriceLinkActivated(link.toString(QUrl::FullyEncoded));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ((link.scheme() == "card") || (link.scheme() == "user")) {
|
if ((link.scheme() == "card") || (link.scheme() == "user")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ private:
|
||||||
void scrollToBottom();
|
void scrollToBottom();
|
||||||
void appendCardTag(QTextCursor &cursor, const QString &cardName);
|
void appendCardTag(QTextCursor &cursor, const QString &cardName);
|
||||||
void appendUrlTag(QTextCursor &cursor, QString url);
|
void appendUrlTag(QTextCursor &cursor, QString url);
|
||||||
|
void appendGameLinkTag(QTextCursor &cursor, const QString &url);
|
||||||
static QColor getCustomMentionColor();
|
static QColor getCustomMentionColor();
|
||||||
static QColor getCustomHighlightColor();
|
static QColor getCustomHighlightColor();
|
||||||
void showSystemPopup(const QString &userName);
|
void showSystemPopup(const QString &userName);
|
||||||
|
|
@ -121,6 +122,7 @@ signals:
|
||||||
void addMentionTag(QString mentionTag);
|
void addMentionTag(QString mentionTag);
|
||||||
void messageClickedSignal();
|
void messageClickedSignal();
|
||||||
void showMentionPopup(const QString &userName);
|
void showMentionPopup(const QString &userName);
|
||||||
|
void cockatriceLinkActivated(const QString &url);
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
23
cockatrice/src/interface/widgets/server/game_link.cpp
Normal file
23
cockatrice/src/interface/widgets/server/game_link.cpp
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
#include "game_link.h"
|
||||||
|
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QUrlQuery>
|
||||||
|
|
||||||
|
QString makeGameJoinLink(const QString &hostname, int port, int roomId, int gameId, const QString &description)
|
||||||
|
{
|
||||||
|
QUrl url;
|
||||||
|
url.setScheme("cockatrice");
|
||||||
|
url.setHost("joingame");
|
||||||
|
QUrlQuery query;
|
||||||
|
query.addQueryItem("hostname", hostname);
|
||||||
|
query.addQueryItem("port", QString::number(port));
|
||||||
|
query.addQueryItem("roomid", QString::number(roomId));
|
||||||
|
query.addQueryItem("gameid", QString::number(gameId));
|
||||||
|
if (!description.isEmpty()) {
|
||||||
|
// addQueryItem percent-encodes, so arbitrary descriptions (quotes,
|
||||||
|
// ampersands, non-ASCII…) survive the trip through chat.
|
||||||
|
query.addQueryItem("game", description);
|
||||||
|
}
|
||||||
|
url.setQuery(query);
|
||||||
|
return url.toString(QUrl::FullyEncoded);
|
||||||
|
}
|
||||||
41
cockatrice/src/interface/widgets/server/game_link.h
Normal file
41
cockatrice/src/interface/widgets/server/game_link.h
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
/**
|
||||||
|
* @file game_link.h
|
||||||
|
* @ingroup UI
|
||||||
|
* @brief Builds cockatrice://joingame links that let another user join a server game.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef GAME_LINK_H
|
||||||
|
#define GAME_LINK_H
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a cockatrice://joingame link for the given server game. The receiver's
|
||||||
|
* client opens it through the intent chain (connect -> join room -> join game).
|
||||||
|
* @p description, when non-empty, is embedded in the link as the URL-encoded
|
||||||
|
* "game" query item so the receiving client can name the game in its confirm
|
||||||
|
* prompt and chat anchor instead of only its numeric id. Links built without it
|
||||||
|
* stay valid: the parser and chat renderer fall back to the id alone.
|
||||||
|
*/
|
||||||
|
QString
|
||||||
|
makeGameJoinLink(const QString &hostname, int port, int roomId, int gameId, const QString &description = QString());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One game the inviter is currently in and can invite another user to.
|
||||||
|
* @p label is meant for display in menus, @p url is the ready-made invite link.
|
||||||
|
* @p description is the raw game description for building tr()-wrapped invite
|
||||||
|
* messages (the label already embeds it, but the send sites need the raw value).
|
||||||
|
* @p onlyBuddies and @p creatorName mirror the server game's room settings so
|
||||||
|
* callers can gate the invite to the creator's buddies.
|
||||||
|
*/
|
||||||
|
struct GameInviteOption
|
||||||
|
{
|
||||||
|
int gameId = 0;
|
||||||
|
QString label;
|
||||||
|
QString url;
|
||||||
|
QString description;
|
||||||
|
bool onlyBuddies = false;
|
||||||
|
QString creatorName;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // GAME_LINK_H
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
#include "../interface/widgets/tabs/tab_room.h"
|
#include "../interface/widgets/tabs/tab_room.h"
|
||||||
#include "../interface/widgets/tabs/tab_supervisor.h"
|
#include "../interface/widgets/tabs/tab_supervisor.h"
|
||||||
#include "../interface/widgets/utility/get_text_with_max.h"
|
#include "../interface/widgets/utility/get_text_with_max.h"
|
||||||
|
#include "game_link.h"
|
||||||
#include "games_model.h"
|
#include "games_model.h"
|
||||||
#include "user/user_list_manager.h"
|
#include "user/user_list_manager.h"
|
||||||
|
|
||||||
|
|
@ -18,8 +19,6 @@
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QTreeView>
|
#include <QTreeView>
|
||||||
#include <QUrl>
|
|
||||||
#include <QUrlQuery>
|
|
||||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||||
#include <libcockatrice/protocol/pb/response.pb.h>
|
#include <libcockatrice/protocol/pb/response.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/room_commands.pb.h>
|
#include <libcockatrice/protocol/pb/room_commands.pb.h>
|
||||||
|
|
@ -320,19 +319,12 @@ void GameSelector::customContextMenu(const QPoint &point)
|
||||||
dlg.exec();
|
dlg.exec();
|
||||||
});
|
});
|
||||||
|
|
||||||
QAction copyLink(tr("Copy Game Link"));
|
QAction copyLink(tr("Cop&y game link"));
|
||||||
connect(©Link, &QAction::triggered, this, [=, this]() {
|
connect(©Link, &QAction::triggered, this, [=, this]() {
|
||||||
const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt());
|
const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt());
|
||||||
QUrl url;
|
QGuiApplication::clipboard()->setText(makeGameJoinLink(client->serverName(), client->serverPort(),
|
||||||
url.setScheme("cockatrice");
|
gameInfo.room_id(), gameInfo.game_id(),
|
||||||
url.setHost("joingame");
|
QString::fromStdString(gameInfo.description())));
|
||||||
QUrlQuery query;
|
|
||||||
query.addQueryItem("hostname", client->serverName());
|
|
||||||
query.addQueryItem("port", QString::number(client->serverPort()));
|
|
||||||
query.addQueryItem("roomid", QString::number(gameInfo.room_id()));
|
|
||||||
query.addQueryItem("gameid", QString::number(gameInfo.game_id()));
|
|
||||||
url.setQuery(query);
|
|
||||||
QGuiApplication::clipboard()->setText(url.toString(QUrl::FullyEncoded));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
QMenu menu;
|
QMenu menu;
|
||||||
|
|
@ -372,13 +364,31 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool spectator = asSpectator || game.player_count() == game.max_players();
|
|
||||||
|
|
||||||
bool overrideRestrictions = !tabSupervisor->getAdminLocked();
|
bool overrideRestrictions = !tabSupervisor->getAdminLocked();
|
||||||
|
|
||||||
|
// Joining a full game without override privileges silently becomes a
|
||||||
|
// spectator join, so ask first instead of surprising the player.
|
||||||
|
const bool gameFull = game.player_count() == game.max_players();
|
||||||
|
if (gameFull && !asSpectator && !asJudge && !overrideRestrictions) {
|
||||||
|
const QMessageBox::StandardButton answer =
|
||||||
|
QMessageBox::question(this, tr("Join game"), tr("The game is full. Join as a spectator instead?"),
|
||||||
|
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
|
||||||
|
if (answer != QMessageBox::Yes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool spectator = asSpectator || gameFull;
|
||||||
|
|
||||||
QString password;
|
QString password;
|
||||||
if (game.with_password() && !(spectator && !game.spectators_need_password()) && !overrideRestrictions) {
|
if (game.with_password() && !(spectator && !game.spectators_need_password()) && !overrideRestrictions) {
|
||||||
bool ok;
|
bool ok;
|
||||||
password = getTextWithMax(this, tr("Join game"), tr("Password:"), QLineEdit::Password, QString(), &ok);
|
// Games without a description have no sensible label — fall back to the
|
||||||
|
// game id so the prompt still tells the user which game they're entering.
|
||||||
|
const QString gameLabel = QString::fromStdString(game.description());
|
||||||
|
const QString prompt = gameLabel.isEmpty() ? tr("Password for game #%1:").arg(game.game_id())
|
||||||
|
: tr("Password for \"%1\":").arg(gameLabel);
|
||||||
|
password = getTextWithMax(this, tr("Join game"), prompt, QLineEdit::Password, QString(), &ok);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -355,6 +355,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
||||||
{
|
{
|
||||||
QAction *aCopyToClipBoard = nullptr, *aRemoveMessages = nullptr;
|
QAction *aCopyToClipBoard = nullptr, *aRemoveMessages = nullptr;
|
||||||
aUserName->setText(userName);
|
aUserName->setText(userName);
|
||||||
|
const bool anotherUser = userName != userListProxy->getOwnUsername();
|
||||||
|
|
||||||
auto *menu = new QMenu(static_cast<QWidget *>(parent()));
|
auto *menu = new QMenu(static_cast<QWidget *>(parent()));
|
||||||
menu->addAction(aUserName);
|
menu->addAction(aUserName);
|
||||||
|
|
@ -366,6 +367,17 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
||||||
menu->addAction(aDetails);
|
menu->addAction(aDetails);
|
||||||
menu->addAction(aShowGames);
|
menu->addAction(aShowGames);
|
||||||
menu->addAction(aChat);
|
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()) {
|
if (userLevel.testFlag(ServerInfo_User::IsRegistered) && userListProxy->isOwnUserRegistered()) {
|
||||||
menu->addSeparator();
|
menu->addSeparator();
|
||||||
if (userListProxy->isUserBuddy(userName)) {
|
if (userListProxy->isUserBuddy(userName)) {
|
||||||
|
|
@ -416,7 +428,6 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
|
||||||
menu->addAction(aPromoteToJudge);
|
menu->addAction(aPromoteToJudge);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bool anotherUser = userName != userListProxy->getOwnUsername();
|
|
||||||
aDetails->setEnabled(true);
|
aDetails->setEnabled(true);
|
||||||
aChat->setEnabled(anotherUser && online);
|
aChat->setEnabled(anotherUser && online);
|
||||||
aShowGames->setEnabled(online);
|
aShowGames->setEnabled(online);
|
||||||
|
|
@ -480,6 +491,60 @@ void UserContextMenu::execChat(const QString &userName)
|
||||||
emit openMessageDialog(userName, true);
|
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)
|
void UserContextMenu::execDetails(const QString &userName)
|
||||||
{
|
{
|
||||||
auto *w = new UserInfoBox(client, false, static_cast<QWidget *>(parent()),
|
auto *w = new UserInfoBox(client, false, static_cast<QWidget *>(parent()),
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,12 @@
|
||||||
#ifndef USER_CONTEXT_MENU_H
|
#ifndef USER_CONTEXT_MENU_H
|
||||||
#define USER_CONTEXT_MENU_H
|
#define USER_CONTEXT_MENU_H
|
||||||
|
|
||||||
#include <QObject>
|
#include "../../interface/widgets/server/game_link.h"
|
||||||
#include <libcockatrice/network/server/remote/user_level.h>
|
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
|
#include <QObject>
|
||||||
|
#include <functional>
|
||||||
|
#include <libcockatrice/network/server/remote/user_level.h>
|
||||||
class AbstractGame;
|
class AbstractGame;
|
||||||
class UserListProxy;
|
class UserListProxy;
|
||||||
class AbstractClient;
|
class AbstractClient;
|
||||||
|
|
@ -43,6 +46,7 @@ private:
|
||||||
QAction *aPromoteToJudge, *aDemoteFromJudge;
|
QAction *aPromoteToJudge, *aDemoteFromJudge;
|
||||||
QAction *aWarnUser, *aWarnHistory;
|
QAction *aWarnUser, *aWarnHistory;
|
||||||
QAction *aGetAdminNotes;
|
QAction *aGetAdminNotes;
|
||||||
|
std::function<QList<GameInviteOption>()> gameInviteLinkProvider;
|
||||||
signals:
|
signals:
|
||||||
void openMessageDialog(const QString &userName, bool focus);
|
void openMessageDialog(const QString &userName, bool focus);
|
||||||
private slots:
|
private slots:
|
||||||
|
|
@ -80,9 +84,28 @@ public:
|
||||||
return userListProxy;
|
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
|
// Individual action entry points — used by UserInfoPopup to trigger
|
||||||
// actions without re-running the full context menu flow.
|
// actions without re-running the full context menu flow.
|
||||||
void execChat(const QString &userName);
|
void execChat(const QString &userName);
|
||||||
|
void execInvite(const QString &userName);
|
||||||
void execDetails(const QString &userName);
|
void execDetails(const QString &userName);
|
||||||
void execShowGames(const QString &userName);
|
void execShowGames(const QString &userName);
|
||||||
void execAddToBuddy(const QString &userName);
|
void execAddToBuddy(const QString &userName);
|
||||||
|
|
@ -97,6 +120,9 @@ public:
|
||||||
void execAdminNotes(const QString &userName);
|
void execAdminNotes(const QString &userName);
|
||||||
void execAdjustMod(const QString &userName, bool shouldBeMod);
|
void execAdjustMod(const QString &userName, bool shouldBeMod);
|
||||||
void execAdjustJudge(const QString &userName, bool shouldBeJudge);
|
void execAdjustJudge(const QString &userName, bool shouldBeJudge);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void execInvite(const QString &userName, const GameInviteOption &option);
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -336,11 +336,12 @@ constexpr int UserInfo = Qt::UserRole + 2;
|
||||||
// rows (UserListTWI, which uses QTreeWidgetItem::Type) by this item type.
|
// rows (UserListTWI, which uses QTreeWidgetItem::Type) by this item type.
|
||||||
constexpr int SectionItemType = QTreeWidgetItem::UserType + 1;
|
constexpr int SectionItemType = QTreeWidgetItem::UserType + 1;
|
||||||
|
|
||||||
UserListItemDelegate::UserListItemDelegate(QTreeWidget *tree,
|
UserListItemDelegate::UserListItemDelegate(UserListWidget *owner,
|
||||||
|
QTreeWidget *tree,
|
||||||
const QMap<QString, QPixmap> *avatarCache,
|
const QMap<QString, QPixmap> *avatarCache,
|
||||||
const QMap<QString, QPixmap> *cardArtCache,
|
const QMap<QString, QPixmap> *cardArtCache,
|
||||||
const QMap<QString, CardArtParams> *cardArtParamsMap)
|
const QMap<QString, CardArtParams> *cardArtParamsMap)
|
||||||
: QStyledItemDelegate(tree), tree(tree), avatarCache(avatarCache), cardArtCache(cardArtCache),
|
: QStyledItemDelegate(tree), tree(tree), owner(owner), avatarCache(avatarCache), cardArtCache(cardArtCache),
|
||||||
cardArtParamsMap(cardArtParamsMap)
|
cardArtParamsMap(cardArtParamsMap)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
@ -353,7 +354,10 @@ bool UserListItemDelegate::editorEvent(QEvent *event,
|
||||||
if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) {
|
if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) {
|
||||||
QMouseEvent *const mouseEvent = static_cast<QMouseEvent *>(event);
|
QMouseEvent *const mouseEvent = static_cast<QMouseEvent *>(event);
|
||||||
if (mouseEvent->button() == Qt::RightButton) {
|
if (mouseEvent->button() == Qt::RightButton) {
|
||||||
static_cast<UserListWidget *>(parent())->showContextMenu(mouseEvent->globalPosition().toPoint(), index);
|
// Dialog mode has no context menu: consume the press, show nothing.
|
||||||
|
if (owner->getHasUserInfoPopup()) {
|
||||||
|
owner->showContextMenu(mouseEvent->globalPosition().toPoint(), index);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -577,8 +581,10 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const
|
||||||
UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
|
UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
|
||||||
AbstractClient *_client,
|
AbstractClient *_client,
|
||||||
UserListType _type,
|
UserListType _type,
|
||||||
QWidget *parent)
|
QWidget *parent,
|
||||||
: QGroupBox(parent), tabSupervisor(_tabSupervisor), client(_client), type(_type), onlineCount(0)
|
bool _hasUserInfoPopup)
|
||||||
|
: QGroupBox(parent), hasUserInfoPopup(_hasUserInfoPopup), tabSupervisor(_tabSupervisor), client(_client),
|
||||||
|
type(_type), onlineCount(0)
|
||||||
{
|
{
|
||||||
avatarProvider = new UserAvatarProvider(client, this);
|
avatarProvider = new UserAvatarProvider(client, this);
|
||||||
cardArtProvider = new UserCardArtProvider(this);
|
cardArtProvider = new UserCardArtProvider(this);
|
||||||
|
|
@ -593,8 +599,8 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
|
||||||
userTree->setHeaderHidden(true);
|
userTree->setHeaderHidden(true);
|
||||||
userTree->setRootIsDecorated(false);
|
userTree->setRootIsDecorated(false);
|
||||||
userTree->setIconSize(QSize(20, 18));
|
userTree->setIconSize(QSize(20, 18));
|
||||||
itemDelegate =
|
itemDelegate = new UserListItemDelegate(this, userTree, &avatarProvider->cache(), &cardArtProvider->cache(),
|
||||||
new UserListItemDelegate(userTree, &avatarProvider->cache(), &cardArtProvider->cache(), &cardArtParamsMap);
|
&cardArtParamsMap);
|
||||||
userTree->setItemDelegate(itemDelegate);
|
userTree->setItemDelegate(itemDelegate);
|
||||||
userTree->setAlternatingRowColors(true);
|
userTree->setAlternatingRowColors(true);
|
||||||
userTree->hideColumn(1);
|
userTree->hideColumn(1);
|
||||||
|
|
@ -604,15 +610,8 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
|
||||||
userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
userTree->header()->setStretchLastSection(true);
|
userTree->header()->setStretchLastSection(true);
|
||||||
|
|
||||||
// ── Hover popup ───────────────────────────────────────────────────────────
|
// Always create timers so callers never segfault on a null deref;
|
||||||
userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(),
|
// showPopupForUser / hidePopup already guard against a null userInfoPopup.
|
||||||
&cardArtProvider->cache(), &cardArtParamsMap,
|
|
||||||
window()); // parented to main window so it floats above siblings
|
|
||||||
|
|
||||||
userInfoPopup->hide();
|
|
||||||
userInfoPopup->setWindowOpacity(0.0);
|
|
||||||
userInfoPopup->installEventFilter(this);
|
|
||||||
|
|
||||||
showPopupTimer = new QTimer(this);
|
showPopupTimer = new QTimer(this);
|
||||||
showPopupTimer->setSingleShot(true);
|
showPopupTimer->setSingleShot(true);
|
||||||
showPopupTimer->setInterval(280);
|
showPopupTimer->setInterval(280);
|
||||||
|
|
@ -638,65 +637,104 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
|
||||||
// The hover ends when the cursor leaves the user row. Empty list
|
// The hover ends when the cursor leaves the user row. Empty list
|
||||||
// space, a section divider and anything outside the tree all close
|
// space, a section divider and anything outside the tree all close
|
||||||
// the popup, while the popup itself keeps it alive.
|
// the popup, while the popup itself keeps it alive.
|
||||||
if (!popupPinned && !userInfoPopup->underMouse() && (hoveredUser.isEmpty() || !userTree->underMouse())) {
|
if (!popupPinned && userInfoPopup && !userInfoPopup->underMouse() &&
|
||||||
|
(hoveredUser.isEmpty() || !userTree->underMouse())) {
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
connectPopupSignals();
|
if (hasUserInfoPopup) {
|
||||||
|
// ── Hover popup ───────────────────────────────────────────────────────
|
||||||
|
userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(),
|
||||||
|
&cardArtProvider->cache(), &cardArtParamsMap,
|
||||||
|
window()); // parented to main window so it floats above siblings
|
||||||
|
|
||||||
|
userInfoPopup->hide();
|
||||||
|
userInfoPopup->setWindowOpacity(0.0);
|
||||||
|
userInfoPopup->installEventFilter(this);
|
||||||
|
|
||||||
|
connectPopupSignals();
|
||||||
|
}
|
||||||
|
|
||||||
userTree->setMouseTracking(true);
|
userTree->setMouseTracking(true);
|
||||||
userTree->viewport()->setMouseTracking(true);
|
userTree->viewport()->setMouseTracking(true);
|
||||||
userTree->viewport()->installEventFilter(this);
|
userTree->viewport()->installEventFilter(this);
|
||||||
userTree->installEventFilter(this); // keyboard handling for section dividers
|
userTree->installEventFilter(this); // keyboard handling for section dividers
|
||||||
|
|
||||||
// Clicking anywhere outside the list clears its selection and closes the
|
if (hasUserInfoPopup) {
|
||||||
// popup. The filter watches all widgets because the press can land on any
|
// Clicking anywhere outside the list clears its selection and closes the
|
||||||
// part of the window, on another list or on the popup itself.
|
// popup. The filter watches all widgets because the press can land on any
|
||||||
qApp->installEventFilter(this);
|
// part of the window, on another list or on the popup itself.
|
||||||
|
qApp->installEventFilter(this);
|
||||||
|
|
||||||
// Pin on item click
|
// Pin on item click
|
||||||
connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) {
|
connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) {
|
||||||
// Clicking a section divider toggles it
|
// Clicking a section divider toggles it
|
||||||
if (sectioned && item->type() == SectionItemType) {
|
if (sectioned && item->type() == SectionItemType) {
|
||||||
setExpandedProgrammatically(item, !item->isExpanded());
|
setExpandedProgrammatically(item, !item->isExpanded());
|
||||||
handleSectionExpansion(item, item->isExpanded());
|
handleSectionExpansion(item, item->isExpanded());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!SettingsCache::instance().appearance().getStyleUserList()) {
|
if (!SettingsCache::instance().appearance().getStyleUserList()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (item->type() != QTreeWidgetItem::Type) {
|
if (item->type() != QTreeWidgetItem::Type) {
|
||||||
return; // divider rows have no user popup
|
return; // divider rows have no user popup
|
||||||
}
|
}
|
||||||
popupPinned = false; // reset so showPopupForUser can update
|
popupPinned = false; // reset so showPopupForUser can update
|
||||||
showPopupForUser(static_cast<UserListTWI *>(item));
|
showPopupForUser(static_cast<UserListTWI *>(item));
|
||||||
popupPinned = true; // pin after showing
|
popupPinned = true; // pin after showing
|
||||||
});
|
});
|
||||||
|
|
||||||
connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this,
|
connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this,
|
||||||
[this](const QItemSelection &sel, const QItemSelection &) {
|
[this](const QItemSelection &sel, const QItemSelection &) {
|
||||||
if (sel.isEmpty() && popupPinned) {
|
if (sel.isEmpty() && popupPinned) {
|
||||||
popupPinned = false;
|
popupPinned = false;
|
||||||
hidePopup();
|
hidePopup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard selection: show the popup for the current row and hide it when
|
// Keyboard selection: the popup is a mouse surface, so keyboard
|
||||||
// the focus moves to a section divider or leaves the list entirely. The
|
// navigation shows no floating popup. A pinned (clicked) popup still
|
||||||
// popup therefore follows arrow key navigation exactly like mouse hover.
|
// follows the selection so it does not strand on a stale user while
|
||||||
// When it was pinned by a click it stays open and follows the selection.
|
// arrows move the cursor.
|
||||||
connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) {
|
connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) {
|
||||||
if (!isVisible() || !SettingsCache::instance().appearance().getStyleUserList()) {
|
if (!popupPinned) {
|
||||||
return;
|
return; // keyboard navigation shows no popup
|
||||||
}
|
}
|
||||||
if (current && current->type() == QTreeWidgetItem::Type) {
|
if (!isVisible() || !SettingsCache::instance().appearance().getStyleUserList()) {
|
||||||
showPopupForUser(static_cast<UserListTWI *>(current));
|
return;
|
||||||
} else {
|
}
|
||||||
popupPinned = false;
|
if (current && current->type() == QTreeWidgetItem::Type) {
|
||||||
hidePopup();
|
showPopupForUser(static_cast<UserListTWI *>(current));
|
||||||
}
|
} else {
|
||||||
});
|
popupPinned = false;
|
||||||
|
hidePopup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide popup when list scrolls (reference row has moved)
|
||||||
|
connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] {
|
||||||
|
showPopupTimer->stop();
|
||||||
|
hidePopup(true);
|
||||||
|
requestAvatarsForVisibleItems();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward join requests from popup upward
|
||||||
|
connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested);
|
||||||
|
} else {
|
||||||
|
// Dialog mode: keyboard selection drives the Invite button.
|
||||||
|
connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) {
|
||||||
|
const QString userName = (current && current->type() == QTreeWidgetItem::Type)
|
||||||
|
? current->data(2, Qt::UserRole).toString()
|
||||||
|
: QString();
|
||||||
|
emit currentUserChanged(userName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the popup-less scroll path alive for avatar prefetch.
|
||||||
|
connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this,
|
||||||
|
[this] { requestAvatarsForVisibleItems(); });
|
||||||
|
}
|
||||||
|
|
||||||
// Section dividers can be collapsed/expanded by the user. Surface those
|
// Section dividers can be collapsed/expanded by the user. Surface those
|
||||||
// changes only from real user interaction. Programmatic expansion is
|
// changes only from real user interaction. Programmatic expansion is
|
||||||
|
|
@ -706,16 +744,6 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
|
||||||
connect(userTree, &QTreeWidget::itemCollapsed, this,
|
connect(userTree, &QTreeWidget::itemCollapsed, this,
|
||||||
[this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); });
|
[this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); });
|
||||||
|
|
||||||
// Hide popup when list scrolls (reference row has moved)
|
|
||||||
connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] {
|
|
||||||
showPopupTimer->stop();
|
|
||||||
hidePopup(true);
|
|
||||||
requestAvatarsForVisibleItems();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forward join requests from popup upward
|
|
||||||
connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested);
|
|
||||||
|
|
||||||
connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, &UserListWidget::refreshVisibleUserHeader);
|
connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, &UserListWidget::refreshVisibleUserHeader);
|
||||||
connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, &UserListWidget::refreshVisibleUserHeader);
|
connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, &UserListWidget::refreshVisibleUserHeader);
|
||||||
|
|
||||||
|
|
@ -838,6 +866,9 @@ void UserListWidget::bind(UserListManager *mgr)
|
||||||
void UserListWidget::refreshVisibleUserHeader(const QString &name)
|
void UserListWidget::refreshVisibleUserHeader(const QString &name)
|
||||||
{
|
{
|
||||||
userTree->viewport()->update();
|
userTree->viewport()->update();
|
||||||
|
if (!userInfoPopup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == name) {
|
if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == name) {
|
||||||
userInfoPopup->refreshHeader();
|
userInfoPopup->refreshHeader();
|
||||||
}
|
}
|
||||||
|
|
@ -845,6 +876,9 @@ void UserListWidget::refreshVisibleUserHeader(const QString &name)
|
||||||
|
|
||||||
void UserListWidget::refreshPopupButtons(const QString &userName)
|
void UserListWidget::refreshPopupButtons(const QString &userName)
|
||||||
{
|
{
|
||||||
|
if (!userInfoPopup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
UserListTWI *item = users.value(userName);
|
UserListTWI *item = users.value(userName);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -862,6 +896,9 @@ void UserListWidget::refreshPopupButtons(const QString &userName)
|
||||||
void UserListWidget::hideEvent(QHideEvent *e)
|
void UserListWidget::hideEvent(QHideEvent *e)
|
||||||
{
|
{
|
||||||
QGroupBox::hideEvent(e);
|
QGroupBox::hideEvent(e);
|
||||||
|
if (!userInfoPopup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
showPopupTimer->stop();
|
showPopupTimer->stop();
|
||||||
hidePopupTimer->stop();
|
hidePopupTimer->stop();
|
||||||
hidePopup(true);
|
hidePopup(true);
|
||||||
|
|
@ -870,6 +907,9 @@ void UserListWidget::hideEvent(QHideEvent *e)
|
||||||
void UserListWidget::showEvent(QShowEvent *e)
|
void UserListWidget::showEvent(QShowEvent *e)
|
||||||
{
|
{
|
||||||
QGroupBox::showEvent(e);
|
QGroupBox::showEvent(e);
|
||||||
|
if (!userInfoPopup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
requestAvatarsForVisibleItems();
|
requestAvatarsForVisibleItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -942,6 +982,24 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyboard entry to the user context menu: the Menu key (or Shift+F10)
|
||||||
|
// pops the same menu the right-click shows, anchored to the focused row.
|
||||||
|
// Divider rows have no menu. Mouse-triggered context events are NOT handled
|
||||||
|
// here — the delegate's right-press path already pops the menu, and
|
||||||
|
// handling both would open two menus on one right-click.
|
||||||
|
if (hasUserInfoPopup && (obj == userTree || obj == userTree->viewport()) && event->type() == QEvent::ContextMenu) {
|
||||||
|
auto *contextEvent = static_cast<QContextMenuEvent *>(event);
|
||||||
|
if (contextEvent->reason() == QContextMenuEvent::Keyboard) {
|
||||||
|
QTreeWidgetItem *current = userTree->currentItem();
|
||||||
|
if (current && current->type() == QTreeWidgetItem::Type) {
|
||||||
|
const QPoint globalPos = userTree->viewport()->mapToGlobal(userTree->visualItemRect(current).center());
|
||||||
|
showContextMenu(globalPos, userTree->indexFromItem(current));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false; // divider rows: no menu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Keyboard navigation of the section dividers.
|
// Keyboard navigation of the section dividers.
|
||||||
// The dividers are selectable so arrow keys land on them. When one is the
|
// The dividers are selectable so arrow keys land on them. When one is the
|
||||||
// current item, Enter/Space toggle it (like a button) and Left/Right follow
|
// current item, Enter/Space toggle it (like a button) and Left/Right follow
|
||||||
|
|
@ -963,7 +1021,7 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (obj == userTree->viewport()) {
|
if (hasUserInfoPopup && obj == userTree->viewport()) {
|
||||||
if (event->type() == QEvent::MouseMove) {
|
if (event->type() == QEvent::MouseMove) {
|
||||||
if (!SettingsCache::instance().appearance().getStyleUserList()) {
|
if (!SettingsCache::instance().appearance().getStyleUserList()) {
|
||||||
return QGroupBox::eventFilter(obj, event);
|
return QGroupBox::eventFilter(obj, event);
|
||||||
|
|
@ -1003,6 +1061,9 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event)
|
||||||
|
|
||||||
void UserListWidget::showPopupForUser(UserListTWI *item)
|
void UserListWidget::showPopupForUser(UserListTWI *item)
|
||||||
{
|
{
|
||||||
|
if (!userInfoPopup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1061,6 +1122,9 @@ void UserListWidget::showPopupForUser(UserListTWI *item)
|
||||||
|
|
||||||
void UserListWidget::positionPopup(UserListTWI *item)
|
void UserListWidget::positionPopup(UserListTWI *item)
|
||||||
{
|
{
|
||||||
|
if (!userInfoPopup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1115,6 +1179,9 @@ void UserListWidget::positionPopup(UserListTWI *item)
|
||||||
|
|
||||||
void UserListWidget::hidePopup(bool immediate)
|
void UserListWidget::hidePopup(bool immediate)
|
||||||
{
|
{
|
||||||
|
if (!userInfoPopup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
showPopupTimer->stop();
|
showPopupTimer->stop();
|
||||||
hidePopupTimer->stop();
|
hidePopupTimer->stop();
|
||||||
if (!userInfoPopup->isVisible()) {
|
if (!userInfoPopup->isVisible()) {
|
||||||
|
|
@ -1475,8 +1542,10 @@ void UserListWidget::applyFilter()
|
||||||
int visible = 0;
|
int visible = 0;
|
||||||
for (int i = 0; i < divider->childCount(); ++i) {
|
for (int i = 0; i < divider->childCount(); ++i) {
|
||||||
auto *child = static_cast<UserListTWI *>(divider->child(i));
|
auto *child = static_cast<UserListTWI *>(divider->child(i));
|
||||||
const bool match =
|
const QString name = QString::fromStdString(child->getUserInfo().name());
|
||||||
!searching || QString::fromStdString(child->getUserInfo().name()).toLower().contains(lower);
|
const bool passesFilter =
|
||||||
|
!userFilter || userFilter(name, child->data(0, UserListRoles::Online).toBool());
|
||||||
|
const bool match = passesFilter && (!searching || name.toLower().contains(lower));
|
||||||
child->setHidden(!match);
|
child->setHidden(!match);
|
||||||
if (match) {
|
if (match) {
|
||||||
++visible;
|
++visible;
|
||||||
|
|
@ -1496,6 +1565,7 @@ void UserListWidget::applyFilter()
|
||||||
}
|
}
|
||||||
requestAvatarsForVisibleItems();
|
requestAvatarsForVisibleItems();
|
||||||
userTree->viewport()->update();
|
userTree->viewport()->update();
|
||||||
|
emit userListChanged();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1513,6 +1583,7 @@ void UserListWidget::applyFilter()
|
||||||
|
|
||||||
requestAvatarsForVisibleItems();
|
requestAvatarsForVisibleItems();
|
||||||
userTree->viewport()->update();
|
userTree->viewport()->update();
|
||||||
|
emit userListChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserListWidget::userClicked(QTreeWidgetItem *item, int /*column*/)
|
void UserListWidget::userClicked(QTreeWidgetItem *item, int /*column*/)
|
||||||
|
|
@ -1520,7 +1591,38 @@ void UserListWidget::userClicked(QTreeWidgetItem *item, int /*column*/)
|
||||||
if (item->type() != QTreeWidgetItem::Type) {
|
if (item->type() != QTreeWidgetItem::Type) {
|
||||||
return; // divider rows open no chat
|
return; // divider rows open no chat
|
||||||
}
|
}
|
||||||
emit openMessageDialog(item->data(2, Qt::UserRole).toString(), true);
|
const QString userName = item->data(2, Qt::UserRole).toString();
|
||||||
|
if (hasUserInfoPopup) {
|
||||||
|
emit openMessageDialog(userName, true);
|
||||||
|
} else {
|
||||||
|
emit userActivated(userName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int UserListWidget::visibleUserRowCount() const
|
||||||
|
{
|
||||||
|
int count = 0;
|
||||||
|
if (sectioned) {
|
||||||
|
for (const Section section : sectionIds) {
|
||||||
|
QTreeWidgetItem *divider = sectionItems.value(section);
|
||||||
|
if (!divider || divider->isHidden()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < divider->childCount(); ++i) {
|
||||||
|
if (!divider->child(i)->isHidden()) {
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < userTree->topLevelItemCount(); ++i) {
|
||||||
|
QTreeWidgetItem *item = userTree->topLevelItem(i);
|
||||||
|
if (!item->isHidden() && item->type() == QTreeWidgetItem::Type) {
|
||||||
|
++count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index)
|
void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index)
|
||||||
|
|
@ -1638,15 +1740,27 @@ void UserListWidget::updateSectionDivider(Section section)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
int visible = 0;
|
int visible = 0;
|
||||||
|
int online = 0;
|
||||||
for (int i = 0; i < divider->childCount(); ++i) {
|
for (int i = 0; i < divider->childCount(); ++i) {
|
||||||
if (!divider->child(i)->isHidden()) {
|
QTreeWidgetItem *child = divider->child(i);
|
||||||
|
if (!child->isHidden()) {
|
||||||
++visible;
|
++visible;
|
||||||
|
if (child->data(0, UserListRoles::Online).toBool()) {
|
||||||
|
++online;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The tree draws no branches (rows are flush), so the divider carries its
|
// The tree draws no branches (rows are flush), so the divider carries its
|
||||||
// own collapse arrow glyph.
|
// own collapse arrow glyph.
|
||||||
const QString arrow = divider->isExpanded() ? QStringLiteral("\u25BE") : QStringLiteral("\u25B8");
|
const QString arrow = divider->isExpanded() ? QStringLiteral("\u25BE") : QStringLiteral("\u25B8");
|
||||||
divider->setText(0, tr("%1 %2 (%3)").arg(arrow, sectionTitle(section)).arg(visible));
|
if (section == Section::Buddy) {
|
||||||
|
// The buddy divider reports how many of the shown buddies are online,
|
||||||
|
// mirroring the "Buddies online: %1 / %2" title of the non-sectioned
|
||||||
|
// buddy list.
|
||||||
|
divider->setText(0, tr("%1 %2 (%3/%4)").arg(arrow, sectionTitle(section)).arg(online).arg(visible));
|
||||||
|
} else {
|
||||||
|
divider->setText(0, tr("%1 %2 (%3)").arg(arrow, sectionTitle(section)).arg(visible));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void UserListWidget::handleSectionExpansion(QTreeWidgetItem *item, bool expanded)
|
void UserListWidget::handleSectionExpansion(QTreeWidgetItem *item, bool expanded)
|
||||||
|
|
@ -1757,6 +1871,13 @@ UserListTWI *UserListWidget::ensureSectionMembership(Section section, const Serv
|
||||||
|
|
||||||
updateCardArtParams(user, userName);
|
updateCardArtParams(user, userName);
|
||||||
|
|
||||||
|
// Dialog mode: rows that fail the user filter never exist. applyFilter()
|
||||||
|
// re-checks the predicate on every pass so a live state change (e.g. the
|
||||||
|
// user being ignored mid-dialog) hides an already created row.
|
||||||
|
if (userFilter && !userFilter(userName, online)) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
QTreeWidgetItem *divider = sectionItems.value(section);
|
QTreeWidgetItem *divider = sectionItems.value(section);
|
||||||
if (!divider) {
|
if (!divider) {
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
|
@ -1825,3 +1946,8 @@ void UserListWidget::finishSectionedMutation()
|
||||||
applyFilter();
|
applyFilter();
|
||||||
userTree->viewport()->update();
|
userTree->viewport()->update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UserListWidget::setGameInviteLinkProvider(std::function<QList<GameInviteOption>()> provider)
|
||||||
|
{
|
||||||
|
userContextMenu->setGameInviteLinkProvider(std::move(provider));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
#define USERLIST_H
|
#define USERLIST_H
|
||||||
|
|
||||||
#include "../../cards/card_info_picture_art_crop_widget.h"
|
#include "../../cards/card_info_picture_art_crop_widget.h"
|
||||||
|
#include "../../interface/widgets/server/game_link.h"
|
||||||
#include "user_avatar_provider.h"
|
#include "user_avatar_provider.h"
|
||||||
#include "user_card_art_provider.h"
|
#include "user_card_art_provider.h"
|
||||||
#include "user_info_popup.h"
|
#include "user_info_popup.h"
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
#include <QStyledItemDelegate>
|
#include <QStyledItemDelegate>
|
||||||
#include <QTextEdit>
|
#include <QTextEdit>
|
||||||
#include <QTreeWidgetItem>
|
#include <QTreeWidgetItem>
|
||||||
|
#include <functional>
|
||||||
#include <libcockatrice/network/server/remote/user_level.h>
|
#include <libcockatrice/network/server/remote/user_level.h>
|
||||||
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
|
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
|
||||||
|
|
||||||
|
|
@ -37,6 +39,7 @@ class QPlainTextEdit;
|
||||||
class Response;
|
class Response;
|
||||||
class CommandContainer;
|
class CommandContainer;
|
||||||
class UserContextMenu;
|
class UserContextMenu;
|
||||||
|
class UserListWidget;
|
||||||
class QShowEvent;
|
class QShowEvent;
|
||||||
|
|
||||||
class BanDialog : public QDialog
|
class BanDialog : public QDialog
|
||||||
|
|
@ -105,12 +108,14 @@ public:
|
||||||
class UserListItemDelegate : public QStyledItemDelegate
|
class UserListItemDelegate : public QStyledItemDelegate
|
||||||
{
|
{
|
||||||
QTreeWidget *tree;
|
QTreeWidget *tree;
|
||||||
|
UserListWidget *owner;
|
||||||
const QMap<QString, QPixmap> *avatarCache;
|
const QMap<QString, QPixmap> *avatarCache;
|
||||||
const QMap<QString, QPixmap> *cardArtCache;
|
const QMap<QString, QPixmap> *cardArtCache;
|
||||||
const QMap<QString, CardArtParams> *cardArtParamsMap;
|
const QMap<QString, CardArtParams> *cardArtParamsMap;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit UserListItemDelegate(QTreeWidget *tree,
|
explicit UserListItemDelegate(UserListWidget *owner,
|
||||||
|
QTreeWidget *tree,
|
||||||
const QMap<QString, QPixmap> *avatarCache,
|
const QMap<QString, QPixmap> *avatarCache,
|
||||||
const QMap<QString, QPixmap> *cardArtCache,
|
const QMap<QString, QPixmap> *cardArtCache,
|
||||||
const QMap<QString, CardArtParams> *cardArtParamsMap);
|
const QMap<QString, CardArtParams> *cardArtParamsMap);
|
||||||
|
|
@ -168,6 +173,8 @@ private:
|
||||||
QString hoveredUser;
|
QString hoveredUser;
|
||||||
bool popupPinned = false;
|
bool popupPinned = false;
|
||||||
bool bulkLoading = false;
|
bool bulkLoading = false;
|
||||||
|
bool hasUserInfoPopup = true;
|
||||||
|
std::function<bool(const QString &userName, bool online)> userFilter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Popup functions are anchored on the row, not the user name. In sectioned
|
* Popup functions are anchored on the row, not the user name. In sectioned
|
||||||
|
|
@ -237,12 +244,19 @@ signals:
|
||||||
void removeIgnore(const QString &userName);
|
void removeIgnore(const QString &userName);
|
||||||
void joinGameRequested(int gameId, int roomId, bool asSpectator);
|
void joinGameRequested(int gameId, int roomId, bool asSpectator);
|
||||||
void sectionExpanded(Section section, bool expanded);
|
void sectionExpanded(Section section, bool expanded);
|
||||||
|
/** Dialog mode: the user activated (Enter/double-click) the given row. */
|
||||||
|
void userActivated(const QString &userName);
|
||||||
|
/** Dialog mode: the current row changed; empty string means no user row. */
|
||||||
|
void currentUserChanged(const QString &userName);
|
||||||
|
/** The set of visible rows changed (filter, search or a live mutation). */
|
||||||
|
void userListChanged();
|
||||||
|
|
||||||
public:
|
public:
|
||||||
UserListWidget(TabSupervisor *_tabSupervisor,
|
UserListWidget(TabSupervisor *_tabSupervisor,
|
||||||
AbstractClient *_client,
|
AbstractClient *_client,
|
||||||
UserListType _type,
|
UserListType _type,
|
||||||
QWidget *parent = nullptr);
|
QWidget *parent = nullptr,
|
||||||
|
bool hasUserInfoPopup = true);
|
||||||
~UserListWidget() override;
|
~UserListWidget() override;
|
||||||
void bind(UserListManager *mgr);
|
void bind(UserListManager *mgr);
|
||||||
void applyDisplayMode();
|
void applyDisplayMode();
|
||||||
|
|
@ -258,6 +272,16 @@ public:
|
||||||
void setShowTitle(bool showTitle);
|
void setShowTitle(bool showTitle);
|
||||||
void setSectioned(const QList<Section> &ids);
|
void setSectioned(const QList<Section> &ids);
|
||||||
void setSectionExpanded(Section section, bool expanded);
|
void setSectionExpanded(Section section, bool expanded);
|
||||||
|
/** Dialog mode: rows that fail the predicate are never shown. */
|
||||||
|
void setUserFilter(std::function<bool(const QString &userName, bool online)> filter)
|
||||||
|
{
|
||||||
|
userFilter = std::move(filter);
|
||||||
|
}
|
||||||
|
[[nodiscard]] int visibleUserRowCount() const;
|
||||||
|
[[nodiscard]] bool getHasUserInfoPopup() const
|
||||||
|
{
|
||||||
|
return hasUserInfoPopup;
|
||||||
|
}
|
||||||
[[nodiscard]] const QList<Section> &getSectionIds() const
|
[[nodiscard]] const QList<Section> &getSectionIds() const
|
||||||
{
|
{
|
||||||
return sectionIds;
|
return sectionIds;
|
||||||
|
|
@ -268,6 +292,7 @@ public:
|
||||||
}
|
}
|
||||||
void showContextMenu(const QPoint &pos, const QModelIndex &index);
|
void showContextMenu(const QPoint &pos, const QModelIndex &index);
|
||||||
void sortItems();
|
void sortItems();
|
||||||
|
void setGameInviteLinkProvider(std::function<QList<GameInviteOption>()> provider);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void hideEvent(QHideEvent *e) override;
|
void hideEvent(QHideEvent *e) override;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class Tab : public QMainWindow
|
||||||
signals:
|
signals:
|
||||||
void userEvent(bool globalEvent = true);
|
void userEvent(bool globalEvent = true);
|
||||||
void tabTextChanged(Tab *tab, const QString &newTabText);
|
void tabTextChanged(Tab *tab, const QString &newTabText);
|
||||||
|
void cockatriceLinkActivated(const QString &url);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
TabSupervisor *tabSupervisor;
|
TabSupervisor *tabSupervisor;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@
|
||||||
#include "../interface/card_picture_loader/card_picture_loader.h"
|
#include "../interface/card_picture_loader/card_picture_loader.h"
|
||||||
#include "../interface/widgets/cards/card_info_frame_widget.h"
|
#include "../interface/widgets/cards/card_info_frame_widget.h"
|
||||||
#include "../interface/widgets/dialogs/dlg_create_game.h"
|
#include "../interface/widgets/dialogs/dlg_create_game.h"
|
||||||
|
#include "../interface/widgets/dialogs/dlg_invite_to_game.h"
|
||||||
|
#include "../interface/widgets/server/game_link.h"
|
||||||
#include "../interface/widgets/server/user/user_list_manager.h"
|
#include "../interface/widgets/server/user/user_list_manager.h"
|
||||||
#include "../interface/widgets/utility/completer_utils.h"
|
#include "../interface/widgets/utility/completer_utils.h"
|
||||||
#include "../interface/widgets/utility/line_edit_completer.h"
|
#include "../interface/widgets/utility/line_edit_completer.h"
|
||||||
|
|
@ -33,6 +35,8 @@
|
||||||
#include "tab_supervisor.h"
|
#include "tab_supervisor.h"
|
||||||
|
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QClipboard>
|
||||||
#include <QCompleter>
|
#include <QCompleter>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QDockWidget>
|
#include <QDockWidget>
|
||||||
|
|
@ -40,10 +44,12 @@
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
|
#include <QPushButton>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
#include <QStackedWidget>
|
#include <QStackedWidget>
|
||||||
#include <QStringListModel>
|
#include <QStringListModel>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
#include <QVBoxLayout>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
#include <libcockatrice/card/database/card_database.h>
|
#include <libcockatrice/card/database/card_database.h>
|
||||||
#include <libcockatrice/card/database/card_database_manager.h>
|
#include <libcockatrice/card/database/card_database_manager.h>
|
||||||
|
|
@ -293,6 +299,9 @@ void TabGame::retranslateUi()
|
||||||
QString tabText = " | " + type + " #" + QString::number(game->getGameMetaInfo()->gameId());
|
QString tabText = " | " + type + " #" + QString::number(game->getGameMetaInfo()->gameId());
|
||||||
|
|
||||||
updatePlayerListDockTitle();
|
updatePlayerListDockTitle();
|
||||||
|
if (inviteButton) {
|
||||||
|
inviteButton->setText(tr("Invite"));
|
||||||
|
}
|
||||||
cardInfoDock->setWindowTitle(tr("Card Info") + (cardInfoDock->isWindow() ? tabText : QString()));
|
cardInfoDock->setWindowTitle(tr("Card Info") + (cardInfoDock->isWindow() ? tabText : QString()));
|
||||||
messageLayoutDock->setWindowTitle(tr("Messages") + (messageLayoutDock->isWindow() ? tabText : QString()));
|
messageLayoutDock->setWindowTitle(tr("Messages") + (messageLayoutDock->isWindow() ? tabText : QString()));
|
||||||
if (replayDock) {
|
if (replayDock) {
|
||||||
|
|
@ -331,6 +340,12 @@ void TabGame::retranslateUi()
|
||||||
if (aGameInfo) {
|
if (aGameInfo) {
|
||||||
aGameInfo->setText(tr("Game &information"));
|
aGameInfo->setText(tr("Game &information"));
|
||||||
}
|
}
|
||||||
|
if (aCopyGameLink) {
|
||||||
|
aCopyGameLink->setText(tr("Cop&y game link"));
|
||||||
|
}
|
||||||
|
if (aInviteToGame) {
|
||||||
|
aInviteToGame->setText(tr("Invite to Game..."));
|
||||||
|
}
|
||||||
if (aConcede) {
|
if (aConcede) {
|
||||||
if (game->getPlayerManager()->isMainPlayerConceded()) {
|
if (game->getPlayerManager()->isMainPlayerConceded()) {
|
||||||
aConcede->setText(tr("Un&concede"));
|
aConcede->setText(tr("Un&concede"));
|
||||||
|
|
@ -498,6 +513,56 @@ void TabGame::actGameInfo()
|
||||||
dlg.exec();
|
dlg.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TabGame::actCopyGameLink()
|
||||||
|
{
|
||||||
|
const QString link =
|
||||||
|
makeGameJoinLink(tabSupervisor->getClient()->serverName(), tabSupervisor->getClient()->serverPort(),
|
||||||
|
game->getGameMetaInfo()->proto().room_id(), game->getGameMetaInfo()->gameId(),
|
||||||
|
QString::fromStdString(game->getGameMetaInfo()->proto().description()));
|
||||||
|
QApplication::clipboard()->setText(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabGame::updateInviteButtonState()
|
||||||
|
{
|
||||||
|
// The dock button stays conservative (pre-start, not full); the menu action
|
||||||
|
// additionally covers started/full games, which are legitimate spectate
|
||||||
|
// invites, so it only needs the server-linked + not-closed conditions.
|
||||||
|
const bool canInvite = !tabSupervisor->getIsLocalGame() && !game->getGameState()->isGameClosed() &&
|
||||||
|
!game->getGameMetaInfo()->started() &&
|
||||||
|
game->getPlayerManager()->getPlayerCount() < game->getGameMetaInfo()->maxPlayers();
|
||||||
|
if (inviteButton) {
|
||||||
|
inviteButton->setVisible(canInvite);
|
||||||
|
}
|
||||||
|
if (aInviteToGame) {
|
||||||
|
aInviteToGame->setEnabled(!tabSupervisor->getIsLocalGame() && !game->getGameState()->isGameClosed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabGame::actInviteToGame()
|
||||||
|
{
|
||||||
|
if (!tabSupervisor || tabSupervisor->getIsLocalGame()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GameMetaInfo *metaInfo = game->getGameMetaInfo();
|
||||||
|
const QString inviteUrl = makeGameJoinLink(
|
||||||
|
tabSupervisor->getClient()->serverName(), tabSupervisor->getClient()->serverPort(), metaInfo->proto().room_id(),
|
||||||
|
metaInfo->gameId(), QString::fromStdString(metaInfo->proto().description()));
|
||||||
|
|
||||||
|
QStringList excludeUserNames;
|
||||||
|
excludeUserNames << tabSupervisor->getUserListManager()->getOwnUsername();
|
||||||
|
for (auto player : game->getPlayerManager()->getPlayers()) {
|
||||||
|
excludeUserNames << player->getPlayerInfo()->getName();
|
||||||
|
}
|
||||||
|
for (auto it = game->getPlayerManager()->getSpectators().cbegin();
|
||||||
|
it != game->getPlayerManager()->getSpectators().cend(); ++it) {
|
||||||
|
excludeUserNames << QString::fromStdString(it.value().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
DlgInviteToGame dlg(tabSupervisor, inviteUrl, metaInfo->proto().only_buddies(), excludeUserNames, this);
|
||||||
|
dlg.exec();
|
||||||
|
}
|
||||||
|
|
||||||
void TabGame::actConcede()
|
void TabGame::actConcede()
|
||||||
{
|
{
|
||||||
PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer());
|
PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer());
|
||||||
|
|
@ -986,6 +1051,11 @@ void TabGame::createMenuItems()
|
||||||
connect(aRotateViewCCW, &QAction::triggered, this, &TabGame::actRotateViewCCW);
|
connect(aRotateViewCCW, &QAction::triggered, this, &TabGame::actRotateViewCCW);
|
||||||
aGameInfo = new QAction(this);
|
aGameInfo = new QAction(this);
|
||||||
connect(aGameInfo, &QAction::triggered, this, &TabGame::actGameInfo);
|
connect(aGameInfo, &QAction::triggered, this, &TabGame::actGameInfo);
|
||||||
|
aCopyGameLink = new QAction(this);
|
||||||
|
aCopyGameLink->setEnabled(!tabSupervisor->getIsLocalGame() && !tabSupervisor->getClient()->serverName().isEmpty());
|
||||||
|
connect(aCopyGameLink, &QAction::triggered, this, &TabGame::actCopyGameLink);
|
||||||
|
aInviteToGame = new QAction(this);
|
||||||
|
connect(aInviteToGame, &QAction::triggered, this, &TabGame::actInviteToGame);
|
||||||
aConcede = new QAction(this);
|
aConcede = new QAction(this);
|
||||||
connect(aConcede, &QAction::triggered, this, &TabGame::actConcede);
|
connect(aConcede, &QAction::triggered, this, &TabGame::actConcede);
|
||||||
if (!game->getGameMetaInfo()->started()) {
|
if (!game->getGameMetaInfo()->started()) {
|
||||||
|
|
@ -1024,6 +1094,8 @@ void TabGame::createMenuItems()
|
||||||
gameMenu->addAction(aRotateViewCCW);
|
gameMenu->addAction(aRotateViewCCW);
|
||||||
gameMenu->addSeparator();
|
gameMenu->addSeparator();
|
||||||
gameMenu->addAction(aGameInfo);
|
gameMenu->addAction(aGameInfo);
|
||||||
|
gameMenu->addAction(aCopyGameLink);
|
||||||
|
gameMenu->addAction(aInviteToGame);
|
||||||
gameMenu->addAction(aConcede);
|
gameMenu->addAction(aConcede);
|
||||||
gameMenu->addAction(aFocusChat);
|
gameMenu->addAction(aFocusChat);
|
||||||
gameMenu->addAction(aLeaveGame);
|
gameMenu->addAction(aLeaveGame);
|
||||||
|
|
@ -1032,6 +1104,9 @@ void TabGame::createMenuItems()
|
||||||
|
|
||||||
aCardMenu = gameMenu->addMenu(new QMenu(this));
|
aCardMenu = gameMenu->addMenu(new QMenu(this));
|
||||||
|
|
||||||
|
// Sync the new action with the same state the dock button already shows.
|
||||||
|
updateInviteButtonState();
|
||||||
|
|
||||||
addTabMenu(gameMenu);
|
addTabMenu(gameMenu);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1046,6 +1121,8 @@ void TabGame::createReplayMenuItems()
|
||||||
aRotateViewCCW = nullptr;
|
aRotateViewCCW = nullptr;
|
||||||
aResetLayout = nullptr;
|
aResetLayout = nullptr;
|
||||||
aGameInfo = nullptr;
|
aGameInfo = nullptr;
|
||||||
|
aCopyGameLink = nullptr;
|
||||||
|
aInviteToGame = nullptr;
|
||||||
aConcede = nullptr;
|
aConcede = nullptr;
|
||||||
aFocusChat = nullptr;
|
aFocusChat = nullptr;
|
||||||
aLeaveGame = new QAction(this);
|
aLeaveGame = new QAction(this);
|
||||||
|
|
@ -1244,11 +1321,29 @@ void TabGame::createPlayerListDock(bool bReplay)
|
||||||
}
|
}
|
||||||
playerListWidget->setFocusPolicy(Qt::NoFocus);
|
playerListWidget->setFocusPolicy(Qt::NoFocus);
|
||||||
|
|
||||||
|
auto *playerListBox = new QWidget(this);
|
||||||
|
auto *vbox = new QVBoxLayout(playerListBox);
|
||||||
|
vbox->setContentsMargins(0, 0, 0, 0);
|
||||||
|
vbox->setSpacing(0);
|
||||||
|
|
||||||
|
vbox->addWidget(playerListWidget);
|
||||||
|
|
||||||
|
if (!bReplay) {
|
||||||
|
inviteButton = new QPushButton(tr("Invite"), playerListBox);
|
||||||
|
inviteButton->setVisible(false);
|
||||||
|
connect(inviteButton, &QPushButton::clicked, this, &TabGame::actInviteToGame);
|
||||||
|
vbox->addWidget(inviteButton);
|
||||||
|
|
||||||
|
connect(game->getGameMetaInfo(), &GameMetaInfo::startedChanged, this, &TabGame::updateInviteButtonState);
|
||||||
|
connect(game->getPlayerManager(), &PlayerManager::playerCountChanged, this, &TabGame::updateInviteButtonState);
|
||||||
|
updateInviteButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
playerListDock = new QDockWidget(this);
|
playerListDock = new QDockWidget(this);
|
||||||
playerListDock->setObjectName("playerListDock");
|
playerListDock->setObjectName("playerListDock");
|
||||||
playerListDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable |
|
playerListDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable |
|
||||||
QDockWidget::DockWidgetMovable);
|
QDockWidget::DockWidgetMovable);
|
||||||
playerListDock->setWidget(playerListWidget);
|
playerListDock->setWidget(playerListBox);
|
||||||
playerListDock->setFloating(false);
|
playerListDock->setFloating(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1272,6 +1367,7 @@ void TabGame::createMessageDock(bool bReplay)
|
||||||
qOverload<const QString &>(&CardInfoFrameWidget::setCard));
|
qOverload<const QString &>(&CardInfoFrameWidget::setCard));
|
||||||
connect(messageLog, &MessageLogWidget::showCardInfoPopup, this, &TabGame::showCardInfoPopup);
|
connect(messageLog, &MessageLogWidget::showCardInfoPopup, this, &TabGame::showCardInfoPopup);
|
||||||
connect(messageLog, &MessageLogWidget::deleteCardInfoPopup, this, &TabGame::deleteCardInfoPopup);
|
connect(messageLog, &MessageLogWidget::deleteCardInfoPopup, this, &TabGame::deleteCardInfoPopup);
|
||||||
|
connect(messageLog, &MessageLogWidget::cockatriceLinkActivated, this, &TabGame::cockatriceLinkActivated);
|
||||||
|
|
||||||
if (!bReplay) {
|
if (!bReplay) {
|
||||||
connect(messageLog, &MessageLogWidget::openMessageDialog, this, &TabGame::openMessageDialog);
|
connect(messageLog, &MessageLogWidget::openMessageDialog, this, &TabGame::openMessageDialog);
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ class CardInfoFrameWidget;
|
||||||
class QTimer;
|
class QTimer;
|
||||||
class QSplitter;
|
class QSplitter;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
|
class QPushButton;
|
||||||
class QToolButton;
|
class QToolButton;
|
||||||
class QMenu;
|
class QMenu;
|
||||||
class ZoneViewLayout;
|
class ZoneViewLayout;
|
||||||
|
|
@ -69,6 +70,7 @@ private:
|
||||||
|
|
||||||
CardInfoFrameWidget *cardInfoFrameWidget;
|
CardInfoFrameWidget *cardInfoFrameWidget;
|
||||||
PlayerListWidget *playerListWidget;
|
PlayerListWidget *playerListWidget;
|
||||||
|
QPushButton *inviteButton = nullptr;
|
||||||
QLabel *timeElapsedLabel;
|
QLabel *timeElapsedLabel;
|
||||||
MessageLogWidget *messageLog;
|
MessageLogWidget *messageLog;
|
||||||
QLabel *sayLabel;
|
QLabel *sayLabel;
|
||||||
|
|
@ -83,9 +85,10 @@ private:
|
||||||
QAction *playersSeparator;
|
QAction *playersSeparator;
|
||||||
QMenu *gameMenu, *viewMenu;
|
QMenu *gameMenu, *viewMenu;
|
||||||
TearOffMenu *phasesMenu;
|
TearOffMenu *phasesMenu;
|
||||||
QAction *aGameInfo, *aConcede, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, *aReverseTurn,
|
QAction *aGameInfo, *aConcede, *aCopyGameLink, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn,
|
||||||
*aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout;
|
*aReverseTurn, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout;
|
||||||
QAction *aFocusChat;
|
QAction *aFocusChat;
|
||||||
|
QAction *aInviteToGame = nullptr;
|
||||||
QList<QAction *> phaseActions;
|
QList<QAction *> phaseActions;
|
||||||
QAction *aCardMenu;
|
QAction *aCardMenu;
|
||||||
|
|
||||||
|
|
@ -128,6 +131,7 @@ private:
|
||||||
void createPlayAreaWidget(bool bReplay = false);
|
void createPlayAreaWidget(bool bReplay = false);
|
||||||
void createDeckViewContainerWidget(bool bReplay = false);
|
void createDeckViewContainerWidget(bool bReplay = false);
|
||||||
void createReplayDock(GameReplay *replay);
|
void createReplayDock(GameReplay *replay);
|
||||||
|
void updateInviteButtonState();
|
||||||
signals:
|
signals:
|
||||||
void gameClosing(TabGame *tab);
|
void gameClosing(TabGame *tab);
|
||||||
void containerProcessingStarted(const GameEventContext &context);
|
void containerProcessingStarted(const GameEventContext &context);
|
||||||
|
|
@ -147,7 +151,9 @@ private slots:
|
||||||
void setCardMenu(CardMenu *menu);
|
void setCardMenu(CardMenu *menu);
|
||||||
|
|
||||||
void actGameInfo();
|
void actGameInfo();
|
||||||
|
void actInviteToGame();
|
||||||
void actConcede();
|
void actConcede();
|
||||||
|
void actCopyGameLink();
|
||||||
void actRemoveLocalArrows();
|
void actRemoveLocalArrows();
|
||||||
void actRotateViewCW();
|
void actRotateViewCW();
|
||||||
void actRotateViewCCW();
|
void actRotateViewCCW();
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,16 @@
|
||||||
TabMessage::TabMessage(TabSupervisor *_tabSupervisor,
|
TabMessage::TabMessage(TabSupervisor *_tabSupervisor,
|
||||||
AbstractClient *_client,
|
AbstractClient *_client,
|
||||||
const ServerInfo_User &_ownUserInfo,
|
const ServerInfo_User &_ownUserInfo,
|
||||||
const ServerInfo_User &_otherUserInfo)
|
const ServerInfo_User &_otherUserInfo,
|
||||||
|
bool _userOnline)
|
||||||
: Tab(_tabSupervisor), client(_client), ownUserInfo(new ServerInfo_User(_ownUserInfo)),
|
: Tab(_tabSupervisor), client(_client), ownUserInfo(new ServerInfo_User(_ownUserInfo)),
|
||||||
otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(true)
|
otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(_userOnline)
|
||||||
{
|
{
|
||||||
chatView = new ChatView(tabSupervisor, 0, true);
|
chatView = new ChatView(tabSupervisor, 0, true);
|
||||||
connect(chatView, &ChatView::showCardInfoPopup, this, &TabMessage::showCardInfoPopup);
|
connect(chatView, &ChatView::showCardInfoPopup, this, &TabMessage::showCardInfoPopup);
|
||||||
connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabMessage::deleteCardInfoPopup);
|
connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabMessage::deleteCardInfoPopup);
|
||||||
connect(chatView, &ChatView::addMentionTag, this, &TabMessage::addMentionTag);
|
connect(chatView, &ChatView::addMentionTag, this, &TabMessage::addMentionTag);
|
||||||
|
connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabMessage::cockatriceLinkActivated);
|
||||||
sayEdit = new LineEditUnfocusable;
|
sayEdit = new LineEditUnfocusable;
|
||||||
sayEdit->setMaxLength(MAX_TEXT_LENGTH);
|
sayEdit->setMaxLength(MAX_TEXT_LENGTH);
|
||||||
connect(sayEdit, &LineEditUnfocusable::returnPressed, this, &TabMessage::sendMessage);
|
connect(sayEdit, &LineEditUnfocusable::returnPressed, this, &TabMessage::sendMessage);
|
||||||
|
|
@ -94,28 +96,58 @@ void TabMessage::closeEvent(QCloseEvent *event)
|
||||||
event->accept();
|
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()
|
void TabMessage::sendMessage()
|
||||||
{
|
{
|
||||||
if (sayEdit->text().isEmpty() || !userOnline) {
|
if (sayEdit->text().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Command_Message cmd;
|
if (!userOnline) {
|
||||||
cmd.set_user_name(otherUserInfo->name());
|
notifyUserOffline();
|
||||||
cmd.set_message(sayEdit->text().toStdString());
|
return;
|
||||||
|
}
|
||||||
PendingCommand *pend = client->prepareSessionCommand(cmd);
|
|
||||||
connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent);
|
|
||||||
client->sendCommand(pend);
|
|
||||||
|
|
||||||
|
sendPrivateMessage(sayEdit->text());
|
||||||
sayEdit->clear();
|
sayEdit->clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void TabMessage::messageSent(const Response &response)
|
bool TabMessage::isUserOnline() const
|
||||||
|
{
|
||||||
|
return userOnline;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabMessage::sendInviteMessage(const QString &text)
|
||||||
|
{
|
||||||
|
sayEdit->setText(text);
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabMessage::messageSent(const Response &response,
|
||||||
|
const CommandContainer & /*commandContainer*/,
|
||||||
|
const QVariant &extraData)
|
||||||
{
|
{
|
||||||
if (response.response_code() == Response::RespInIgnoreList) {
|
if (response.response_code() == Response::RespInIgnoreList) {
|
||||||
chatView->appendMessage(tr(
|
chatView->appendMessage(tr(
|
||||||
"This user is ignoring you, they cannot see your messages in main chat and you cannot join their games."));
|
"This user is ignoring you, they cannot see your messages in main chat and you cannot join their games."));
|
||||||
|
} else if (response.response_code() == Response::RespNameNotFound) {
|
||||||
|
// The recipient went offline before the command reached the server: restore the draft.
|
||||||
|
userOnline = false;
|
||||||
|
if (sayEdit->text().isEmpty()) {
|
||||||
|
sayEdit->setText(extraData.toString());
|
||||||
|
}
|
||||||
|
notifyUserOffline();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,3 +207,8 @@ void TabMessage::processUserJoined(const ServerInfo_User &_userInfo)
|
||||||
userOnline = true;
|
userOnline = true;
|
||||||
*otherUserInfo = _userInfo;
|
*otherUserInfo = _userInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TabMessage::notifyUserOffline()
|
||||||
|
{
|
||||||
|
chatView->appendMessage(tr("Message not sent — %1 is offline.").arg(QString::fromStdString(otherUserInfo->name())));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class LineEditUnfocusable;
|
||||||
class Event_UserMessage;
|
class Event_UserMessage;
|
||||||
class Response;
|
class Response;
|
||||||
class ServerInfo_User;
|
class ServerInfo_User;
|
||||||
|
class CommandContainer;
|
||||||
|
|
||||||
class TabMessage : public Tab
|
class TabMessage : public Tab
|
||||||
{
|
{
|
||||||
|
|
@ -39,7 +40,7 @@ signals:
|
||||||
void maximizeClient();
|
void maximizeClient();
|
||||||
private slots:
|
private slots:
|
||||||
void sendMessage();
|
void sendMessage();
|
||||||
void messageSent(const Response &response);
|
void messageSent(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
|
||||||
void addMentionTag(QString mentionTag);
|
void addMentionTag(QString mentionTag);
|
||||||
void messageClicked();
|
void messageClicked();
|
||||||
|
|
||||||
|
|
@ -50,7 +51,8 @@ public:
|
||||||
TabMessage(TabSupervisor *_tabSupervisor,
|
TabMessage(TabSupervisor *_tabSupervisor,
|
||||||
AbstractClient *_client,
|
AbstractClient *_client,
|
||||||
const ServerInfo_User &_ownUserInfo,
|
const ServerInfo_User &_ownUserInfo,
|
||||||
const ServerInfo_User &_otherUserInfo);
|
const ServerInfo_User &_otherUserInfo,
|
||||||
|
bool _userOnline);
|
||||||
~TabMessage() override;
|
~TabMessage() override;
|
||||||
void retranslateUi() override;
|
void retranslateUi() override;
|
||||||
void tabActivated() override;
|
void tabActivated() override;
|
||||||
|
|
@ -62,9 +64,14 @@ public:
|
||||||
void processUserLeft();
|
void processUserLeft();
|
||||||
void processUserJoined(const ServerInfo_User &_userInfo);
|
void processUserJoined(const ServerInfo_User &_userInfo);
|
||||||
|
|
||||||
|
[[nodiscard]] bool isUserOnline() const;
|
||||||
|
void sendPrivateMessage(const QString &text);
|
||||||
|
void sendInviteMessage(const QString &text);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool shouldShowSystemPopup(const Event_UserMessage &event);
|
bool shouldShowSystemPopup(const Event_UserMessage &event);
|
||||||
void showSystemPopup(const Event_UserMessage &event);
|
void showSystemPopup(const Event_UserMessage &event);
|
||||||
|
void notifyUserOffline();
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
#include "../../../client/settings/shortcuts_settings.h"
|
#include "../../../client/settings/shortcuts_settings.h"
|
||||||
#include "../interface/widgets/dialogs/dlg_settings.h"
|
#include "../interface/widgets/dialogs/dlg_settings.h"
|
||||||
#include "../interface/widgets/server/chat_view/chat_view.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/game_selector.h"
|
||||||
#include "../interface/widgets/server/user/user_list_manager.h"
|
#include "../interface/widgets/server/user/user_list_manager.h"
|
||||||
#include "../interface/widgets/server/user/user_list_panel_widget.h"
|
#include "../interface/widgets/server/user/user_list_panel_widget.h"
|
||||||
|
|
@ -30,6 +31,7 @@
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QtCore/qdatetime.h>
|
#include <QtCore/qdatetime.h>
|
||||||
|
#include <functional>
|
||||||
#include <libcockatrice/card/database/card_database_manager.h>
|
#include <libcockatrice/card/database/card_database_manager.h>
|
||||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||||
#include <libcockatrice/protocol/get_pb_extension.h>
|
#include <libcockatrice/protocol/get_pb_extension.h>
|
||||||
|
|
@ -66,10 +68,14 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
|
||||||
userList = userListPanel->getUserList();
|
userList = userListPanel->getUserList();
|
||||||
connect(userListPanel, &UserListPanelWidget::openMessageDialog, this, &TabRoom::openMessageDialog);
|
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);
|
chatView = new ChatView(tabSupervisor, nullptr, true, this);
|
||||||
connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup);
|
connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup);
|
||||||
connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab);
|
connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab);
|
||||||
connect(chatView, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog);
|
connect(chatView, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog);
|
||||||
|
connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated);
|
||||||
connect(chatView, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup);
|
connect(chatView, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup);
|
||||||
connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup);
|
connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup);
|
||||||
connect(chatView, &ChatView::addMentionTag, this, &TabRoom::addMentionTag);
|
connect(chatView, &ChatView::addMentionTag, this, &TabRoom::addMentionTag);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
#include "../../../client/settings/cache_settings.h"
|
#include "../../../client/settings/cache_settings.h"
|
||||||
#include "../../../client/settings/shortcuts_settings.h"
|
#include "../../../client/settings/shortcuts_settings.h"
|
||||||
#include "../interface/pixel_map_generator.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_manager.h"
|
||||||
#include "../interface/widgets/server/user/user_list_widget.h"
|
#include "../interface/widgets/server/user/user_list_widget.h"
|
||||||
#include "../main.h"
|
#include "../main.h"
|
||||||
|
|
@ -406,6 +407,7 @@ int TabSupervisor::myAddTab(Tab *tab, QAction *manager)
|
||||||
{
|
{
|
||||||
connect(tab, &TabGame::userEvent, this, &TabSupervisor::tabUserEvent);
|
connect(tab, &TabGame::userEvent, this, &TabSupervisor::tabUserEvent);
|
||||||
connect(tab, &TabGame::tabTextChanged, this, &TabSupervisor::updateTabText);
|
connect(tab, &TabGame::tabTextChanged, this, &TabSupervisor::updateTabText);
|
||||||
|
connect(tab, &TabGame::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated);
|
||||||
|
|
||||||
QString tabText = tab->getTabText();
|
QString tabText = tab->getTabText();
|
||||||
int idx = addTab(tab, sanitizeTabName(tabText));
|
int idx = addTab(tab, sanitizeTabName(tabText));
|
||||||
|
|
@ -851,6 +853,7 @@ void TabSupervisor::addRoomTab(const ServerInfo_Room &info, bool setCurrent)
|
||||||
connect(tab, &TabRoom::maximizeClient, this, &TabSupervisor::maximizeMainWindow);
|
connect(tab, &TabRoom::maximizeClient, this, &TabSupervisor::maximizeMainWindow);
|
||||||
connect(tab, &TabRoom::roomClosing, this, &TabSupervisor::roomLeft);
|
connect(tab, &TabRoom::roomClosing, this, &TabSupervisor::roomLeft);
|
||||||
connect(tab, &TabRoom::openMessageDialog, this, &TabSupervisor::addMessageTab);
|
connect(tab, &TabRoom::openMessageDialog, this, &TabSupervisor::addMessageTab);
|
||||||
|
connect(tab, &TabRoom::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated);
|
||||||
myAddTab(tab);
|
myAddTab(tab);
|
||||||
roomTabs.insert(info.room_id(), tab);
|
roomTabs.insert(info.room_id(), tab);
|
||||||
if (setCurrent) {
|
if (setCurrent) {
|
||||||
|
|
@ -904,8 +907,10 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus
|
||||||
}
|
}
|
||||||
|
|
||||||
ServerInfo_User otherUser;
|
ServerInfo_User otherUser;
|
||||||
|
bool userOnline = false;
|
||||||
if (auto user = userListManager->getOnlineUser(receiverName)) {
|
if (auto user = userListManager->getOnlineUser(receiverName)) {
|
||||||
otherUser = ServerInfo_User(*user);
|
otherUser = ServerInfo_User(*user);
|
||||||
|
userOnline = true;
|
||||||
} else {
|
} else {
|
||||||
otherUser.set_name(receiverName.toStdString());
|
otherUser.set_name(receiverName.toStdString());
|
||||||
}
|
}
|
||||||
|
|
@ -919,9 +924,10 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus
|
||||||
return tab;
|
return tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
tab = new TabMessage(this, client, *userInfo, otherUser);
|
tab = new TabMessage(this, client, *userInfo, otherUser, userOnline);
|
||||||
connect(tab, &TabMessage::talkClosing, this, &TabSupervisor::talkLeft);
|
connect(tab, &TabMessage::talkClosing, this, &TabSupervisor::talkLeft);
|
||||||
connect(tab, &TabMessage::maximizeClient, this, &TabSupervisor::maximizeMainWindow);
|
connect(tab, &TabMessage::maximizeClient, this, &TabSupervisor::maximizeMainWindow);
|
||||||
|
connect(tab, &TabMessage::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated);
|
||||||
myAddTab(tab);
|
myAddTab(tab);
|
||||||
messageTabs.insert(receiverName, tab);
|
messageTabs.insert(receiverName, tab);
|
||||||
if (focus) {
|
if (focus) {
|
||||||
|
|
@ -945,6 +951,53 @@ void TabSupervisor::talkLeft(TabMessage *tab)
|
||||||
removeTab(indexOf(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->sendInviteMessage(inviteText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new deck editor tab and loads the deck into it.
|
* Creates a new deck editor tab and loads the deck into it.
|
||||||
* Creates either a classic or visual deck editor tab depending on settings
|
* Creates either a classic or visual deck editor tab depending on settings
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
#define TAB_SUPERVISOR_H
|
#define TAB_SUPERVISOR_H
|
||||||
|
|
||||||
#include "../../deck_loader/deck_loader.h"
|
#include "../../deck_loader/deck_loader.h"
|
||||||
|
#include "../interface/widgets/server/game_link.h"
|
||||||
#include "../interface/widgets/server/user/user_list_proxy.h"
|
#include "../interface/widgets/server/user/user_list_proxy.h"
|
||||||
#include "abstract_tab_deck_editor.h"
|
#include "abstract_tab_deck_editor.h"
|
||||||
#include "api/archidekt/tab_archidekt.h"
|
#include "api/archidekt/tab_archidekt.h"
|
||||||
|
|
@ -160,6 +161,8 @@ public:
|
||||||
{
|
{
|
||||||
return deckEditorTabs;
|
return deckEditorTabs;
|
||||||
}
|
}
|
||||||
|
[[nodiscard]] QList<GameInviteOption> getGameInviteLinksForRoom(int roomId) const;
|
||||||
|
void sendInviteToUser(const QString &userName, const QString &inviteText);
|
||||||
[[nodiscard]] bool getAdminLocked() const;
|
[[nodiscard]] bool getAdminLocked() const;
|
||||||
void closeEvent(QCloseEvent *event) override;
|
void closeEvent(QCloseEvent *event) override;
|
||||||
bool switchToGameTabIfAlreadyExists(const int gameId);
|
bool switchToGameTabIfAlreadyExists(const int gameId);
|
||||||
|
|
@ -169,6 +172,7 @@ signals:
|
||||||
void localGameEnded();
|
void localGameEnded();
|
||||||
void adminLockChanged(bool lock);
|
void adminLockChanged(bool lock);
|
||||||
void showWindowIfHidden();
|
void showWindowIfHidden();
|
||||||
|
void cockatriceLinkActivated(const QString &url);
|
||||||
|
|
||||||
public slots:
|
public slots:
|
||||||
void openDeckInNewTab(const LoadedDeck &deckToOpen);
|
void openDeckInNewTab(const LoadedDeck &deckToOpen);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -40,6 +40,7 @@
|
||||||
#include "intents/intent_connect_to_server.h"
|
#include "intents/intent_connect_to_server.h"
|
||||||
#include "intents/intent_login.h"
|
#include "intents/intent_login.h"
|
||||||
#include "intents/intent_open_server_room_by_name.h"
|
#include "intents/intent_open_server_room_by_name.h"
|
||||||
|
#include "intents/url_parser.h"
|
||||||
#include "logger.h"
|
#include "logger.h"
|
||||||
#include "version_string.h"
|
#include "version_string.h"
|
||||||
#include "widgets/dialogs/dlg_connect.h"
|
#include "widgets/dialogs/dlg_connect.h"
|
||||||
|
|
@ -497,6 +498,7 @@ MainWindow::MainWindow(QWidget *parent)
|
||||||
pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize());
|
pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize());
|
||||||
|
|
||||||
connectionController = new ConnectionController(this, this);
|
connectionController = new ConnectionController(this, this);
|
||||||
|
urlParser = new IntentUrlParser(this, this);
|
||||||
|
|
||||||
createActions();
|
createActions();
|
||||||
createMenus();
|
createMenus();
|
||||||
|
|
@ -508,6 +510,7 @@ MainWindow::MainWindow(QWidget *parent)
|
||||||
connect(tabSupervisor, &TabSupervisor::setMenu, this, &MainWindow::updateTabMenu);
|
connect(tabSupervisor, &TabSupervisor::setMenu, this, &MainWindow::updateTabMenu);
|
||||||
connect(tabSupervisor, &TabSupervisor::localGameEnded, this, &MainWindow::localGameEnded);
|
connect(tabSupervisor, &TabSupervisor::localGameEnded, this, &MainWindow::localGameEnded);
|
||||||
connect(tabSupervisor, &TabSupervisor::showWindowIfHidden, this, &MainWindow::showWindowIfHidden);
|
connect(tabSupervisor, &TabSupervisor::showWindowIfHidden, this, &MainWindow::showWindowIfHidden);
|
||||||
|
connect(tabSupervisor, &TabSupervisor::cockatriceLinkActivated, this, &MainWindow::handleCockatriceLink);
|
||||||
connect(connectionController, &ConnectionController::tabSupervisorStartRequested, tabSupervisor,
|
connect(connectionController, &ConnectionController::tabSupervisorStartRequested, tabSupervisor,
|
||||||
&TabSupervisor::start);
|
&TabSupervisor::start);
|
||||||
connect(connectionController, &ConnectionController::tabSupervisorStopRequested, tabSupervisor,
|
connect(connectionController, &ConnectionController::tabSupervisorStopRequested, tabSupervisor,
|
||||||
|
|
@ -861,6 +864,11 @@ void MainWindow::showWindowIfHidden()
|
||||||
show();
|
show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::handleCockatriceLink(const QString &url)
|
||||||
|
{
|
||||||
|
urlParser->handle(url);
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::cardDatabaseLoadingFailed()
|
void MainWindow::cardDatabaseLoadingFailed()
|
||||||
{
|
{
|
||||||
if (askedForDbUpdater) {
|
if (askedForDbUpdater) {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ class TabSupervisor;
|
||||||
class WndSets;
|
class WndSets;
|
||||||
class DlgTipOfTheDay;
|
class DlgTipOfTheDay;
|
||||||
struct ContextConnectToServer;
|
struct ContextConnectToServer;
|
||||||
|
class IntentUrlParser;
|
||||||
|
|
||||||
class MainWindow : public QMainWindow
|
class MainWindow : public QMainWindow
|
||||||
{
|
{
|
||||||
|
|
@ -84,6 +85,7 @@ private slots:
|
||||||
void actOpenSettingsFolder();
|
void actOpenSettingsFolder();
|
||||||
void actShow();
|
void actShow();
|
||||||
void showWindowIfHidden();
|
void showWindowIfHidden();
|
||||||
|
void handleCockatriceLink(const QString &url);
|
||||||
|
|
||||||
void cardUpdateError(QProcess::ProcessError err);
|
void cardUpdateError(QProcess::ProcessError err);
|
||||||
void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus);
|
void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus);
|
||||||
|
|
@ -139,6 +141,7 @@ private:
|
||||||
*aOpenSettingsFolder;
|
*aOpenSettingsFolder;
|
||||||
|
|
||||||
TabSupervisor *tabSupervisor;
|
TabSupervisor *tabSupervisor;
|
||||||
|
IntentUrlParser *urlParser;
|
||||||
WndSets *wndSets;
|
WndSets *wndSets;
|
||||||
ConnectionController *connectionController;
|
ConnectionController *connectionController;
|
||||||
LocalServer *localServer;
|
LocalServer *localServer;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ set(HEADERS
|
||||||
game/server_cardzone.h
|
game/server_cardzone.h
|
||||||
game/server_counter.h
|
game/server_counter.h
|
||||||
game/game_config.h
|
game/game_config.h
|
||||||
|
game/server_deck_validation_strategy.h
|
||||||
game/server_game.h
|
game/server_game.h
|
||||||
game/server_player.h
|
game/server_player.h
|
||||||
game/server_spectator.h
|
game/server_spectator.h
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -62,7 +62,8 @@ Server_Game::Server_Game(const GameConfig &config, Server_Room *_room)
|
||||||
spectatorsCanTalk(config.spectatorsCanTalk), spectatorsSeeEverything(config.spectatorsSeeEverything),
|
spectatorsCanTalk(config.spectatorsCanTalk), spectatorsSeeEverything(config.spectatorsSeeEverything),
|
||||||
startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad),
|
startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad),
|
||||||
inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false),
|
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 = new GameReplay;
|
||||||
currentReplay->set_replay_id(room->getServer()->getDatabaseInterface()->getNextReplayId());
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,13 @@
|
||||||
|
|
||||||
#include "../server_response_containers.h"
|
#include "../server_response_containers.h"
|
||||||
#include "game_config.h"
|
#include "game_config.h"
|
||||||
|
#include "server_deck_validation_strategy.h"
|
||||||
|
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
#include <QMutex>
|
#include <QMutex>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include <QScopedPointer>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <libcockatrice/protocol/pb/event_leave.pb.h>
|
#include <libcockatrice/protocol/pb/event_leave.pb.h>
|
||||||
|
|
@ -79,6 +81,8 @@ private:
|
||||||
QList<GameReplay *> replayList;
|
QList<GameReplay *> replayList;
|
||||||
GameReplay *currentReplay;
|
GameReplay *currentReplay;
|
||||||
|
|
||||||
|
QScopedPointer<Server_DeckValidationStrategy> deckValidationStrategy;
|
||||||
|
|
||||||
void createGameStateChangedEvent(Event_GameStateChanged *event,
|
void createGameStateChangedEvent(Event_GameStateChanged *event,
|
||||||
Server_AbstractParticipant *recipient,
|
Server_AbstractParticipant *recipient,
|
||||||
bool omniscient,
|
bool omniscient,
|
||||||
|
|
@ -208,6 +212,14 @@ public:
|
||||||
GameEventStorageItem::SendToOthers,
|
GameEventStorageItem::SendToOthers,
|
||||||
int privatePlayerId = -1);
|
int privatePlayerId = -1);
|
||||||
void returnCardsFromPlayer(GameEventStorage &ges, Server_AbstractPlayer *player);
|
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
|
#endif
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue