From 8007d40a9055ab66e8e2435e3c37979bcffc1cd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 15 Jun 2026 19:42:11 +0200 Subject: [PATCH] Hover popup. Took 36 minutes Took 1 minute --- cockatrice/CMakeLists.txt | 2 + .../widgets/server/user/user_context_menu.cpp | 110 ++++ .../widgets/server/user/user_context_menu.h | 21 + .../widgets/server/user/user_info_popup.cpp | 599 ++++++++++++++++++ .../widgets/server/user/user_info_popup.h | 174 +++++ .../widgets/server/user/user_list_widget.cpp | 207 ++++++ .../widgets/server/user/user_list_widget.h | 13 + 7 files changed, 1126 insertions(+) create mode 100644 cockatrice/src/interface/widgets/server/user/user_info_popup.cpp create mode 100644 cockatrice/src/interface/widgets/server/user/user_info_popup.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index fca7d27c8..18679664b 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -354,6 +354,8 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h src/interface/widgets/utility/compact_push_button.cpp src/interface/widgets/utility/compact_push_button.h + src/interface/widgets/server/user/user_info_popup.cpp + src/interface/widgets/server/user/user_info_popup.h ) add_subdirectory(sounds) diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 195b1cc8d..1b05148ce 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -537,3 +537,113 @@ void UserContextMenu::showContextMenu(const QPoint &pos, delete menu; } + +void UserContextMenu::execChat(const QString &userName) +{ + emit openMessageDialog(userName, true); +} + +void UserContextMenu::execDetails(const QString &userName) +{ + auto *w = new UserInfoBox(client, false, static_cast(parent()), + Qt::Dialog | Qt::WindowTitleHint | Qt::CustomizeWindowHint | Qt::WindowCloseButtonHint); + w->setAttribute(Qt::WA_DeleteOnClose); + w->updateInfo(userName); +} + +void UserContextMenu::execShowGames(const QString &userName) +{ + Command_GetGamesOfUser cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::gamesOfUserReceived); + client->sendCommand(pend); +} + +void UserContextMenu::execAddToBuddy(const QString &userName) +{ + Command_AddToList cmd; + cmd.set_list("buddy"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execRemoveFromBuddy(const QString &userName) +{ + Command_RemoveFromList cmd; + cmd.set_list("buddy"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execAddToIgnore(const QString &userName) +{ + Command_AddToList cmd; + cmd.set_list("ignore"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execRemoveFromIgnore(const QString &userName) +{ + Command_RemoveFromList cmd; + cmd.set_list("ignore"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execBan(const QString &userName) +{ + Command_GetUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::banUser_processUserInfoResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execWarn(const QString &userName) +{ + Command_GetUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::warnUser_processUserInfoResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execBanHistory(const QString &userName) +{ + Command_GetBanHistory cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::banUserHistory_processResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execWarnHistory(const QString &userName) +{ + Command_GetWarnHistory cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::warnUserHistory_processResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execAdminNotes(const QString &userName) +{ + Command_GetAdminNotes cmd; + cmd.set_user_name(userName.toStdString()); + auto *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::getAdminNotes_processResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod, bool shouldBeJudge) +{ + Command_AdjustMod cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_should_be_mod(shouldBeMod); + cmd.set_should_be_judge(shouldBeJudge); + PendingCommand *pend = client->prepareAdminCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); + client->sendCommand(pend); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index b0ff89816..28173bfbc 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -74,6 +74,27 @@ public: int playerId, const QString &deckHash, ChatView *chatView = nullptr); + + const UserListProxy *getUserListProxy() const + { + return userListProxy; + } + + // Individual action entry points — used by UserInfoPopup to trigger + // actions without re-running the full context menu flow. + void execChat(const QString &userName); + void execDetails(const QString &userName); + void execShowGames(const QString &userName); + void execAddToBuddy(const QString &userName); + void execRemoveFromBuddy(const QString &userName); + void execAddToIgnore(const QString &userName); + void execRemoveFromIgnore(const QString &userName); + void execBan(const QString &userName); + void execWarn(const QString &userName); + void execBanHistory(const QString &userName); + void execWarnHistory(const QString &userName); + void execAdminNotes(const QString &userName); + void execAdjustMod(const QString &userName, bool shouldBeMod, bool shouldBeJudge); }; #endif diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp new file mode 100644 index 000000000..ca41d504c --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -0,0 +1,599 @@ +#include "user_info_popup.h" + +#include "../../interface/pixel_map_generator.h" +#include "../../interface/widgets/tabs/tab_supervisor.h" +#include "user_list_painter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ── Compact game row delegate ───────────────────────────────────────────────── + +class PopupGameDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + + QSize sizeHint(const QStyleOptionViewItem &, const QModelIndex &) const override + { + return QSize(0, 38); + } + + void paint(QPainter *p, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + const QVariant var = index.data(PopupRoles::GameData); + if (!var.isValid()) { + QStyledItemDelegate::paint(p, option, index); + return; + } + + p->save(); + p->setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing); + + const QRect rect = option.rect; + const ServerInfo_Game game = var.value(); + const bool selected = option.state & QStyle::State_Selected; + + p->fillRect(rect, selected ? QColor(35, 45, 62) : QColor(14, 18, 26)); + + // State colour dot + const QColor dot = game.started() ? QColor(239, 68, 68) + : (game.player_count() >= game.max_players()) ? QColor(249, 115, 22) + : game.with_password() ? QColor(59, 130, 246) + : QColor(34, 197, 94); + p->setPen(Qt::NoPen); + p->setBrush(dot); + p->drawEllipse(QRectF(rect.left() + 9, rect.top() + (rect.height() - 8) / 2.0, 8, 8)); + + // Game title (bold, elided) + QFont tf = option.font; + tf.setBold(true); + p->setFont(tf); + p->setPen(QColor(205, 215, 230)); + const int textX = rect.left() + 26; + const int countW = 52; + const int titleW = rect.width() - textX - countW - 6; + p->drawText(QRect(textX, rect.top(), titleW, rect.height()), Qt::AlignVCenter | Qt::AlignLeft, + QFontMetrics(tf).elidedText(QString::fromStdString(game.description()), Qt::ElideRight, titleW)); + + // Player count + const bool full = game.player_count() >= game.max_players(); + p->setFont(option.font); + p->setPen(full ? QColor(249, 115, 22) : QColor(110, 128, 150)); + p->drawText(QRect(rect.right() - countW - 4, rect.top(), countW, rect.height()), + Qt::AlignVCenter | Qt::AlignRight, + QStringLiteral("%1/%2").arg(game.player_count()).arg(game.max_players())); + + // Row separator + p->setPen(QColor(24, 32, 44)); + p->drawLine(rect.bottomLeft(), rect.bottomRight()); + + p->restore(); + } +}; + +// ── UserInfoHeaderWidget ────────────────────────────────────────────────────── + +UserInfoHeaderWidget::UserInfoHeaderWidget(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(HeaderHeight); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} + +void UserInfoHeaderWidget::setUserData(const ServerInfo_User &user, + bool online, + const QPixmap &avatar, + const QPixmap &cardArt, + const CardArtParams ¶ms) +{ + m_user = user; + m_online = online; + m_avatar = avatar; + m_cardArt = cardArt; + m_params = params; + update(); +} + +void UserInfoHeaderWidget::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + const QRect rect = this->rect(); + const UserLevelFlags level(m_user.user_level()); + const QString userName = QString::fromStdString(m_user.name()); + const QString privLevel = QString::fromStdString(m_user.privlevel()); + + // Dark base + p.fillRect(rect, QColor(14, 18, 26)); + + // ── Card art background ─────────────────────────────────────────────────── + if (!m_cardArt.isNull()) { + const int w = rect.width(); + const int h = rect.height(); + const int mL = qRound(w * m_params.marginPctL); + const int mR = qRound(w * m_params.marginPctR); + const int dW = w - mL - mR; + + const double base = qMax(double(dW) / m_cardArt.width(), double(h) / m_cardArt.height()); + const double scale = base * m_params.zoom; + const int sW = qRound(m_cardArt.width() * scale); + const int sH = qRound(m_cardArt.height() * scale); + + const QPixmap scaled = m_cardArt.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + const int srcX = (sW - dW) / 2; + const int srcY = qBound(0, qRound((sH - h) * m_params.verticalOffset), qMax(0, sH - h)); + + QImage img = scaled.copy(srcX, srcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); + { + QPainter mask(&img); + mask.setCompositionMode(QPainter::CompositionMode_DestinationIn); + QLinearGradient g(0, 0, img.width(), 0); + g.setColorAt(0.00, Qt::transparent); + g.setColorAt(0.18, Qt::white); + g.setColorAt(0.82, Qt::white); + g.setColorAt(1.00, Qt::transparent); + mask.fillRect(img.rect(), g); + } + p.setOpacity(0.48); + p.drawImage(mL, 0, img); + p.setOpacity(1.0); + } + + // Bottom gradient overlay so avatar and text are always legible + { + QLinearGradient ov(0, 0, 0, rect.height()); + ov.setColorAt(0.0, QColor(14, 18, 26, 0)); + ov.setColorAt(0.55, QColor(14, 18, 26, 110)); + ov.setColorAt(1.0, QColor(14, 18, 26, 230)); + p.fillRect(rect, ov); + } + + // ── Avatar ──────────────────────────────────────────────────────────────── + const QColor accent = [&]() -> QColor { + if (level.testFlag(ServerInfo_User::IsAdmin)) { + return QColor(245, 158, 11); + } + if (level.testFlag(ServerInfo_User::IsModerator)) { + return QColor(59, 130, 246); + } + if (level.testFlag(ServerInfo_User::IsJudge)) { + return QColor(168, 85, 247); + } + return QColor(100, 116, 139); + }(); + + const int ax = LeftPad; + const int ay = rect.height() - AvatarSize - 10; + const QRect ar(ax, ay, AvatarSize, AvatarSize); + + QPainterPath clip; + clip.addEllipse(ar); + p.save(); + p.setClipPath(clip); + + if (!m_avatar.isNull()) { + p.drawPixmap(ar, m_avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + } else { + p.setPen(Qt::NoPen); + p.setBrush(accent.darker(200)); + p.drawEllipse(ar); + const QPixmap pawn = + UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, m_user.pawn_colors(), false, privLevel); + p.drawPixmap(ar.center().x() - AvatarPawnSize / 2, ar.center().y() - AvatarPawnSize / 2, pawn); + } + p.restore(); + + // Status ring + p.setPen(QPen(m_online ? QColor(34, 197, 94) : QColor(70, 80, 95), 2.5)); + p.setBrush(Qt::NoBrush); + p.drawEllipse(QRectF(ar).adjusted(-1.25, -1.25, 1.25, 1.25)); + + // ── Username + badge ────────────────────────────────────────────────────── + const int tx = ax + AvatarSize + AvatarToTextGap; + const int tw = rect.width() - tx - 8; + + QFont nf = font(); + nf.setBold(true); + nf.setPointSizeF(nf.pointSizeF() * 1.12); + p.setFont(nf); + p.setPen(m_online ? QColor(220, 228, 240) : QColor(90, 100, 115)); + p.drawText(QRect(tx, ay, tw, AvatarSize / 2 + 4), Qt::AlignBottom | Qt::AlignLeft, + QFontMetrics(nf).elidedText(userName, Qt::ElideRight, tw)); + + // Level / priv badge + struct + { + QString text; + QColor color; + } badge; + if (level.testFlag(ServerInfo_User::IsAdmin)) { + badge = {"ADMIN", QColor(245, 158, 11)}; + } else if (level.testFlag(ServerInfo_User::IsModerator)) { + badge = {"MOD", QColor(59, 130, 246)}; + } else if (level.testFlag(ServerInfo_User::IsJudge)) { + badge = {"JUDGE", QColor(168, 85, 247)}; + } else if (privLevel == "VIP") { + badge = {"VIP", QColor(20, 184, 166)}; + } else if (privLevel == "DONATOR") { + badge = {"DONATOR", QColor(249, 115, 22)}; + } + + if (!badge.text.isEmpty()) { + QFont bf = font(); + bf.setPointSizeF(bf.pointSizeF() * 0.70); + bf.setBold(true); + p.setFont(bf); + const QFontMetrics bfm(bf); + const int bw = bfm.horizontalAdvance(badge.text) + 10; + const QRect br(tx, ay + AvatarSize / 2 + 6, bw, 15); + p.setPen(Qt::NoPen); + p.setBrush(badge.color.darker(160)); + p.drawRoundedRect(br, 3, 3); + p.setPen(badge.color.lighter(150)); + p.drawText(br, Qt::AlignCenter, badge.text); + } +} + +// ── UserInfoPopup ───────────────────────────────────────────────────────────── + +UserInfoPopup::UserInfoPopup(TabSupervisor *ts, + AbstractClient *client, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap, + QWidget *parent) + : QFrame(parent, Qt::Tool | Qt::FramelessWindowHint), m_ts(ts), m_client(client), m_avatarCache(avatarCache), + m_cardArtCache(cardArtCache), m_cardArtParamsMap(cardArtParamsMap) +{ + setAttribute(Qt::WA_ShowWithoutActivating); + setFixedWidth(PopupWidth); + setFrameShape(QFrame::NoFrame); + buildUi(); +} + +void UserInfoPopup::buildUi() +{ + setStyleSheet(QStringLiteral("UserInfoPopup {" + " background:#0e1218;" + " border:1px solid #1e2838;" + " border-radius:8px;" + "}")); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + // Header + m_header = new UserInfoHeaderWidget(this); + root->addWidget(m_header); + + // Action area — rebuilt per user + m_actionArea = new QWidget(this); + m_actionArea->setStyleSheet(QStringLiteral("background:#0e1218;")); + root->addWidget(m_actionArea); + + // Thin separator + auto *sep = new QFrame(this); + sep->setFrameShape(QFrame::HLine); + sep->setStyleSheet(QStringLiteral("color:#1a2434; margin: 0 8px;")); + root->addWidget(sep); + + // Games header row + auto *gh = new QHBoxLayout; + gh->setContentsMargins(10, 4, 8, 2); + auto *gl = new QLabel(tr("Games"), this); + gl->setStyleSheet(QStringLiteral("color:#6882a0; font-size:11px; font-weight:bold; background:transparent;")); + gh->addWidget(gl); + gh->addStretch(); + m_refreshBtn = new QPushButton(QStringLiteral("↻"), this); + m_refreshBtn->setFixedSize(20, 20); + m_refreshBtn->setFlat(true); + m_refreshBtn->setStyleSheet( + QStringLiteral("QPushButton{color:#6882a0;border:none;font-size:14px;background:transparent;}" + "QPushButton:hover{color:white;}")); + connect(m_refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames); + gh->addWidget(m_refreshBtn); + root->addLayout(gh); + + // Status label + m_gamesStatus = new QLabel(this); + m_gamesStatus->setAlignment(Qt::AlignCenter); + m_gamesStatus->setStyleSheet( + QStringLiteral("color:#3a4a5e; font-size:11px; padding:10px; background:transparent;")); + root->addWidget(m_gamesStatus); + + // Games list + m_gamesModel = new QStandardItemModel(this); + m_gamesView = new QListView(this); + m_gamesView->setModel(m_gamesModel); + m_gamesView->setItemDelegate(new PopupGameDelegate(m_gamesView)); + m_gamesView->setFrameShape(QFrame::NoFrame); + m_gamesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_gamesView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_gamesView->setMaximumHeight(220); + m_gamesView->setStyleSheet(QStringLiteral("QListView{background:#0e1218;border:none;}" + "QListView::item:selected{background:#232e42;}")); + root->addWidget(m_gamesView); + + // Close button — positioned absolutely in the top-right corner + m_closeBtn = new QPushButton(QStringLiteral("✕"), this); + m_closeBtn->setFixedSize(22, 22); + m_closeBtn->setFlat(true); + m_closeBtn->setStyleSheet(QStringLiteral("QPushButton{background:rgba(14,18,26,180);color:#607080;" + "border:none;border-radius:11px;font-size:10px;}" + "QPushButton:hover{color:white;background:rgba(200,50,50,200);}")); + connect(m_closeBtn, &QPushButton::clicked, this, &UserInfoPopup::closeRequested); +} + +// ── Action button factory ───────────────────────────────────────────────────── + +static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p) +{ + auto *b = new QPushButton(label, p); + b->setToolTip(tip); + b->setFixedHeight(26); + b->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + b->setStyleSheet(QStringLiteral("QPushButton{" + " background:#192030;color:#b8c8de;border:1px solid #263040;" + " border-radius:4px;font-size:11px;padding:0 4px;" + "}" + "QPushButton:hover{background:#223050;color:white;}" + "QPushButton:pressed{background:#162030;}" + "QPushButton:disabled{color:#384858;border-color:#192030;}")); + return b; +} + +void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) +{ + // Clear previous contents + delete m_actionArea->layout(); + const auto old = m_actionArea->findChildren(QString{}, Qt::FindDirectChildrenOnly); + for (auto *w : old) { + w->deleteLater(); + } + + const QString name = QString::fromStdString(userInfo.name()); + const auto ownLevel = UserLevelFlags(m_ts->getUserInfo()->user_level()); + const bool isSelf = (name == QString::fromStdString(m_ts->getUserInfo()->name())); + const bool isMod = ownLevel.testFlag(ServerInfo_User::IsModerator); + const bool isAdmin = ownLevel.testFlag(ServerInfo_User::IsAdmin); + const auto their = UserLevelFlags(userInfo.user_level()); + const bool isReg = their.testFlag(ServerInfo_User::IsRegistered); + + auto *grid = new QGridLayout(m_actionArea); + grid->setContentsMargins(8, 6, 8, 6); + grid->setSpacing(4); + + int row = 0, col = 0; + const int cols = 3; + auto add = [&](QPushButton *btn) { + grid->addWidget(btn, row, col); + if (++col == cols) { + col = 0; + ++row; + } + }; + + // ── Always visible ──────────────────────────────────────────────────────── + auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), m_actionArea); + chat->setEnabled(!isSelf && online); + connect(chat, &QPushButton::clicked, this, [this, name] { emit chatRequested(name); }); + add(chat); + + auto *prof = makeBtn(tr("Profile"), tr("View user profile"), m_actionArea); + connect(prof, &QPushButton::clicked, this, [this, name] { emit detailsRequested(name); }); + add(prof); + + auto *games = makeBtn(tr("Games"), tr("Show this user's games"), m_actionArea); + games->setEnabled(!isSelf && online); + connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); }); + add(games); + + // ── Buddy / ignore (registered users only) ──────────────────────────────── + if (!isSelf && isReg) { + if (isBuddy) { + auto *b = makeBtn(tr("− Buddy"), tr("Remove from buddy list"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit removeBuddyRequested(name); }); + add(b); + } else { + auto *b = makeBtn(tr("+ Buddy"), tr("Add to buddy list"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit addBuddyRequested(name); }); + add(b); + } + if (isIgnored) { + auto *b = makeBtn(tr("− Ignore"), tr("Remove from ignore list"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit removeIgnoreRequested(name); }); + add(b); + } else { + auto *b = makeBtn(tr("+ Ignore"), tr("Add to ignore list"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit addIgnoreRequested(name); }); + add(b); + } + } + + // ── Moderator actions ───────────────────────────────────────────────────── + if (!isSelf && (isMod || isAdmin)) { + if (col != 0) { + ++row; + col = 0; + } // start mod section on a fresh row + + auto *ban = makeBtn(tr("Ban"), tr("Ban from server"), m_actionArea); + auto *warn = makeBtn(tr("Warn"), tr("Warn user"), m_actionArea); + auto *bLog = makeBtn(tr("Ban log"), tr("View ban history"), m_actionArea); + auto *wLog = makeBtn(tr("Warn log"), tr("View warning history"), m_actionArea); + connect(ban, &QPushButton::clicked, this, [this, name] { emit banRequested(name); }); + connect(warn, &QPushButton::clicked, this, [this, name] { emit warnRequested(name); }); + connect(bLog, &QPushButton::clicked, this, [this, name] { emit banHistoryRequested(name); }); + connect(wLog, &QPushButton::clicked, this, [this, name] { emit warnHistoryRequested(name); }); + add(ban); + add(warn); + add(bLog); + add(wLog); + } + + // ── Admin actions ───────────────────────────────────────────────────────── + if (!isSelf && isAdmin) { + auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), m_actionArea); + connect(notes, &QPushButton::clicked, this, [this, name] { emit adminNotesRequested(name); }); + add(notes); + + if (their.testFlag(ServerInfo_User::IsModerator)) { + auto *b = makeBtn(tr("− Mod"), tr("Demote from moderator"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromModRequested(name); }); + add(b); + } else if (isReg) { + auto *b = makeBtn(tr("+ Mod"), tr("Promote to moderator"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToModRequested(name); }); + add(b); + } + if (their.testFlag(ServerInfo_User::IsJudge)) { + auto *b = makeBtn(tr("− Judge"), tr("Demote from judge"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromJudgeRequested(name); }); + add(b); + } else if (isReg) { + auto *b = makeBtn(tr("+ Judge"), tr("Promote to judge"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToJudgeRequested(name); }); + add(b); + } + } + + m_actionArea->adjustSize(); +} + +// ── showForUser ─────────────────────────────────────────────────────────────── + +void UserInfoPopup::showForUser(const QString &userName, + const ServerInfo_User &userInfo, + bool online, + bool isBuddy, + bool isIgnored) +{ + m_currentUser = userName; + m_currentUserInfo = userInfo; + m_currentOnline = online; + + // Header + const QPixmap avatar = m_avatarCache ? m_avatarCache->value(userName) : QPixmap{}; + const CardArtParams params = (m_cardArtParamsMap && m_cardArtParamsMap->contains(userName)) + ? m_cardArtParamsMap->value(userName) + : CardArtParams{}; + const QString artKey = userName + u'|' + params.cardName; + const QPixmap cardArt = (m_cardArtCache && !params.cardName.isEmpty()) ? m_cardArtCache->value(artKey) : QPixmap{}; + m_header->setUserData(userInfo, online, avatar, cardArt, params); + + // Actions + rebuildActionButtons(userInfo, online, isBuddy, isIgnored); + + // Games list reset + m_gamesModel->clear(); + m_gamesView->hide(); + m_gamesStatus->setText(tr("Loading games…")); + m_gamesStatus->show(); + + // Close button — top-right corner, above everything + m_closeBtn->move(PopupWidth - m_closeBtn->width() - 6, 6); + m_closeBtn->raise(); + + adjustSize(); + fetchGames(); +} + +// ── Games fetch ─────────────────────────────────────────────────────────────── + +void UserInfoPopup::fetchGames() +{ + if (!m_client || m_currentUser.isEmpty()) { + return; + } + + Command_GetGamesOfUser cmd; + cmd.set_user_name(m_currentUser.toStdString()); + + const QString snapshot = m_currentUser; + PendingCommand *pend = m_client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, snapshot](const Response &r) { onGamesReceived(r, snapshot); }); + m_client->sendCommand(pend); +} + +void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) +{ + if (forUser != m_currentUser) { + return; // stale response — different user showing now + } + + m_gamesModel->clear(); + + if (r.response_code() != Response::RespOk) { + m_gamesStatus->setText(tr("Could not load games.")); + m_gamesStatus->show(); + m_gamesView->hide(); + return; + } + + const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext); + if (resp.game_list_size() == 0) { + m_gamesStatus->setText(tr("No active games.")); + m_gamesStatus->show(); + m_gamesView->hide(); + return; + } + + for (int i = 0; i < resp.game_list_size(); ++i) { + auto *item = new QStandardItem; + item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData); + item->setEditable(false); + m_gamesModel->appendRow(item); + } + + m_gamesStatus->hide(); + m_gamesView->show(); + adjustSize(); +} + +void UserInfoPopup::refreshGames() +{ + m_gamesModel->clear(); + m_gamesView->hide(); + m_gamesStatus->setText(tr("Loading games…")); + m_gamesStatus->show(); + fetchGames(); +} + +// ── Mouse events ────────────────────────────────────────────────────────────── + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +void UserInfoPopup::enterEvent(QEnterEvent *e) +{ + QFrame::enterEvent(e); + emit mouseEnteredPopup(); +} +#else +void UserInfoPopup::enterEvent(QEvent *e) +{ + QFrame::enterEvent(e); + emit mouseEnteredPopup(); +} +#endif +void UserInfoPopup::leaveEvent(QEvent *e) +{ + QFrame::leaveEvent(e); + emit mouseLeftPopup(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.h b/cockatrice/src/interface/widgets/server/user/user_info_popup.h new file mode 100644 index 000000000..87892265f --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -0,0 +1,174 @@ +#ifndef COCKATRICE_USER_INFO_POPUP_H +#define COCKATRICE_USER_INFO_POPUP_H + +#include "../../interface/widgets/server/game_type_map.h" +#include "user_list_painter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class AbstractClient; +class QLabel; +class QPushButton; +class TabSupervisor; + +// ── Roles ───────────────────────────────────────────────────────────────────── + +namespace PopupRoles +{ +constexpr int GameData = Qt::UserRole + 10; +} + +// ── Header widget ───────────────────────────────────────────────────────────── + +/** + * @class UserInfoHeaderWidget + * @brief Paints the enlarged banner card art + circular avatar section at the + * top of the UserInfoPopup. + * + * Layout mirrors UserListPainter but at a larger scale: the card art fills the + * full width as a semi-transparent background, a bottom gradient ensures the + * avatar and username text remain legible, and the status ring colour matches + * the UserListPainter convention. + */ +class UserInfoHeaderWidget : public QWidget +{ + Q_OBJECT + + static constexpr int HeaderHeight = 130; + static constexpr int AvatarSize = 68; + static constexpr int AvatarPawnSize = 46; + static constexpr int LeftPad = 14; + static constexpr int AvatarToTextGap = 10; + +public: + explicit UserInfoHeaderWidget(QWidget *parent = nullptr); + + void setUserData(const ServerInfo_User &user, + bool online, + const QPixmap &avatar, + const QPixmap &cardArt, + const CardArtParams ¶ms); + +protected: + void paintEvent(QPaintEvent *e) override; + +private: + ServerInfo_User m_user; + bool m_online = false; + QPixmap m_avatar; + QPixmap m_cardArt; + CardArtParams m_params; +}; + +// ── Main popup ──────────────────────────────────────────────────────────────── + +/** + * @class UserInfoPopup + * @brief Floating panel showing an enlarged user card, quick action buttons, + * and a live scrollable games list. + * + * Lifecycle (mirrors DeckEditorDeckDockWidget): + * - showForUser() — populate, position externally, call show() + * - mouseEnteredPopup / mouseLeftPopup — caller manages hide timer + * - closeRequested() — emitted by the internal close button + * + * The popup is a Qt::Tool frameless child so windowOpacity animations and + * move() in screen coordinates work identically to CardInfoPictureEnlargedWidget. + * + * Action signals map 1-to-1 to UserContextMenu::exec*() methods so all action + * logic stays in one place. + */ +class UserInfoPopup : public QFrame +{ + Q_OBJECT + + static constexpr int PopupWidth = 316; + +public: + explicit UserInfoPopup(TabSupervisor *tabSupervisor, + AbstractClient *client, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap, + QWidget *parent); + + /** + * Populate the popup for @p userName and kick off a game list fetch. + * Call show() / move() externally after this. + */ + void + showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); + void fetchGames(); + + [[nodiscard]] QString currentUser() const + { + return m_currentUser; + } + +signals: + void mouseEnteredPopup(); + void mouseLeftPopup(); + void closeRequested(); + + // ── Action signals — connect to UserContextMenu::exec*() ────────────────── + void chatRequested(const QString &userName); + void detailsRequested(const QString &userName); + void showGamesRequested(const QString &userName); + void addBuddyRequested(const QString &userName); + void removeBuddyRequested(const QString &userName); + void addIgnoreRequested(const QString &userName); + void removeIgnoreRequested(const QString &userName); + void banRequested(const QString &userName); + void warnRequested(const QString &userName); + void banHistoryRequested(const QString &userName); + void warnHistoryRequested(const QString &userName); + void adminNotesRequested(const QString &userName); + void promoteToModRequested(const QString &userName); + void demoteFromModRequested(const QString &userName); + void promoteToJudgeRequested(const QString &userName); + void demoteFromJudgeRequested(const QString &userName); + +protected: +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + void enterEvent(QEnterEvent *e) override; +#else + void enterEvent(QEvent *e) override; +#endif + void leaveEvent(QEvent *e) override; + +private slots: + void refreshGames(); + void onGamesReceived(const Response &r, const QString &forUser); + +private: + void buildUi(); + void rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); + + TabSupervisor *m_ts; + AbstractClient *m_client; + const QMap *m_avatarCache; + const QMap *m_cardArtCache; + const QMap *m_cardArtParamsMap; + + QString m_currentUser; + ServerInfo_User m_currentUserInfo; + bool m_currentOnline = false; + + UserInfoHeaderWidget *m_header; + QWidget *m_actionArea; ///< rebuilt per user + QListView *m_gamesView; + QStandardItemModel *m_gamesModel; + QLabel *m_gamesStatus; + QPushButton *m_closeBtn; + QPushButton *m_refreshBtn; +}; + +#endif // COCKATRICE_USER_INFO_POPUP_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index accc5e336..28a9689b4 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -489,6 +489,59 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); userTree->header()->setStretchLastSection(true); + // ── Hover popup ─────────────────────────────────────────────────────────── + m_userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(), + &cardArtProvider->cache(), &cardArtParamsMap, + window()); // parented to main window so it floats above siblings + + m_userInfoPopup->hide(); + m_userInfoPopup->setWindowOpacity(0.0); + m_userInfoPopup->installEventFilter(this); + + connectPopupSignals(); + + m_showPopupTimer = new QTimer(this); + m_showPopupTimer->setSingleShot(true); + m_showPopupTimer->setInterval(280); + connect(m_showPopupTimer, &QTimer::timeout, this, [this] { + if (!m_hoveredUser.isEmpty()) { + showPopupForUser(m_hoveredUser); + } + }); + + m_hidePopupTimer = new QTimer(this); + m_hidePopupTimer->setSingleShot(true); + m_hidePopupTimer->setInterval(160); + connect(m_hidePopupTimer, &QTimer::timeout, this, [this] { + if (!m_popupPinned && !m_userInfoPopup->underMouse() && !userTree->underMouse()) { + hidePopup(); + } + }); + + userTree->setMouseTracking(true); + userTree->viewport()->setMouseTracking(true); + userTree->viewport()->installEventFilter(this); + + // Pin on item click + connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { + if (!SettingsCache::instance().getStyleUserList()) { + return; + } + const QString name = static_cast(item)->getUserInfo().name().c_str(); + m_popupPinned = false; // reset so showPopupForUser can update + showPopupForUser(name); + m_popupPinned = true; // pin after showing + }); + + // Unpin when selection cleared + connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this](const QItemSelection &sel, const QItemSelection &) { + if (sel.isEmpty() && m_popupPinned) { + m_popupPinned = false; + hidePopup(); + } + }); + connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, [this](const QString &) { userTree->viewport()->update(); }); connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, @@ -533,6 +586,160 @@ void UserListWidget::applyDisplayMode() userTree->viewport()->update(); } +void UserListWidget::connectPopupSignals() +{ + connect(m_userInfoPopup, &UserInfoPopup::closeRequested, this, [this] { + m_popupPinned = false; + hidePopup(true); + }); + connect(m_userInfoPopup, &UserInfoPopup::mouseEnteredPopup, m_hidePopupTimer, &QTimer::stop); + connect(m_userInfoPopup, &UserInfoPopup::mouseLeftPopup, this, [this] { + if (!m_popupPinned) { + m_hidePopupTimer->start(); + } + }); + + // Wire all action signals to UserContextMenu::exec*() + connect(m_userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat); + connect(m_userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails); + connect(m_userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames); + connect(m_userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy); + connect(m_userInfoPopup, &UserInfoPopup::removeBuddyRequested, userContextMenu, + &UserContextMenu::execRemoveFromBuddy); + connect(m_userInfoPopup, &UserInfoPopup::addIgnoreRequested, userContextMenu, &UserContextMenu::execAddToIgnore); + connect(m_userInfoPopup, &UserInfoPopup::removeIgnoreRequested, userContextMenu, + &UserContextMenu::execRemoveFromIgnore); + connect(m_userInfoPopup, &UserInfoPopup::banRequested, userContextMenu, &UserContextMenu::execBan); + connect(m_userInfoPopup, &UserInfoPopup::warnRequested, userContextMenu, &UserContextMenu::execWarn); + connect(m_userInfoPopup, &UserInfoPopup::banHistoryRequested, userContextMenu, &UserContextMenu::execBanHistory); + connect(m_userInfoPopup, &UserInfoPopup::warnHistoryRequested, userContextMenu, &UserContextMenu::execWarnHistory); + connect(m_userInfoPopup, &UserInfoPopup::adminNotesRequested, userContextMenu, &UserContextMenu::execAdminNotes); + connect(m_userInfoPopup, &UserInfoPopup::promoteToModRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, true, false); }); + connect(m_userInfoPopup, &UserInfoPopup::demoteFromModRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, false, false); }); + connect(m_userInfoPopup, &UserInfoPopup::promoteToJudgeRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, false, true); }); + connect(m_userInfoPopup, &UserInfoPopup::demoteFromJudgeRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, false, false); }); +} + +bool UserListWidget::eventFilter(QObject *obj, QEvent *event) +{ + if (obj == userTree->viewport()) { + if (event->type() == QEvent::MouseMove) { + if (!SettingsCache::instance().getStyleUserList()) { + return QGroupBox::eventFilter(obj, event); + } + auto *me = static_cast(event); + auto *twi = static_cast(userTree->itemAt(me->pos())); + const QString hovName = twi ? QString::fromStdString(twi->getUserInfo().name()) : QString{}; + + if (hovName != m_hoveredUser) { + m_hoveredUser = hovName; + if (!hovName.isEmpty()) { + m_hidePopupTimer->stop(); + if (!m_popupPinned) { + m_showPopupTimer->start(); + } + } else { + m_showPopupTimer->stop(); + if (!m_popupPinned) { + m_hidePopupTimer->start(); + } + } + } + } else if (event->type() == QEvent::Leave) { + m_hoveredUser.clear(); + m_showPopupTimer->stop(); + if (!m_popupPinned) { + m_hidePopupTimer->start(); + } + } + } + + return QGroupBox::eventFilter(obj, event); +} + +void UserListWidget::showPopupForUser(const QString &userName) +{ + UserListTWI *item = users.value(userName); + if (!item) { + return; + } + + const ServerInfo_User &info = item->getUserInfo(); + const bool online = item->data(0, UserListRoles::Online).toBool(); + const bool isBuddy = userContextMenu->getUserListProxy()->isUserBuddy(userName); + const bool isIgn = userContextMenu->getUserListProxy()->isUserIgnored(userName); + + m_userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn); + + positionPopup(userName); + + m_userInfoPopup->show(); + m_userInfoPopup->raise(); + + // Fade in + m_userInfoPopup->setWindowOpacity(0.0); + auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); + fade->setDuration(120); + fade->setStartValue(0.0); + fade->setEndValue(1.0); + fade->start(QAbstractAnimation::DeleteWhenStopped); +} + +void UserListWidget::positionPopup(const QString &userName) +{ + UserListTWI *item = users.value(userName); + if (!item) { + return; + } + + QWidget *vp = userTree->viewport(); + const QRect itemR = userTree->visualItemRect(item); + const QPoint itemTL = vp->mapToGlobal(itemR.topLeft()); + const QPoint vpTL = vp->mapToGlobal(vp->rect().topLeft()); + + const int popW = m_userInfoPopup->width(); + const int popH = m_userInfoPopup->sizeHint().height(); + const int margin = 12; + + // Go left of the list if there's room, otherwise right + int x = + (vpTL.x() >= popW + margin) ? vpTL.x() - popW - margin : vp->mapToGlobal(vp->rect().topRight()).x() + margin; + + // Align top with the hovered row, clamped to available screen space + const QRect screen = QGuiApplication::primaryScreen()->availableGeometry(); + int y = itemTL.y(); + y = qMin(y, screen.bottom() - popH - margin); + y = qMax(y, screen.top() + margin); + + m_userInfoPopup->move(x, y); +} + +void UserListWidget::hidePopup(bool immediate) +{ + m_showPopupTimer->stop(); + m_hidePopupTimer->stop(); + if (!m_userInfoPopup->isVisible()) { + return; + } + + if (immediate) { + m_userInfoPopup->hide(); + return; + } + + // Fade out + auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); + fade->setDuration(100); + fade->setStartValue(m_userInfoPopup->windowOpacity()); + fade->setEndValue(0.0); + connect(fade, &QPropertyAnimation::finished, m_userInfoPopup, &QWidget::hide); + fade->start(QAbstractAnimation::DeleteWhenStopped); +} + void UserListWidget::retranslateUi() { userContextMenu->retranslateUi(); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index 367164700..5fd1fa4b8 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -10,6 +10,7 @@ #include "../../cards/card_info_picture_art_crop_widget.h" #include "user_avatar_provider.h" #include "user_card_art_provider.h" +#include "user_info_popup.h" #include "user_list_manager.h" #include "user_list_painter.h" @@ -151,6 +152,17 @@ private: UserAvatarProvider *avatarProvider = nullptr; UserCardArtProvider *cardArtProvider = nullptr; QMap cardArtParamsMap; + // ── Hover popup ─────────────────────────────────────────────────────────── + UserInfoPopup *m_userInfoPopup = nullptr; + QTimer *m_showPopupTimer = nullptr; + QTimer *m_hidePopupTimer = nullptr; + QString m_hoveredUser; + bool m_popupPinned = false; + + void showPopupForUser(const QString &userName); + void hidePopup(bool immediate = false); + void positionPopup(const QString &userName); + void connectPopupSignals(); QMap users; TabSupervisor *tabSupervisor; @@ -178,6 +190,7 @@ public: QWidget *parent = nullptr); void bind(UserListManager *mgr); void applyDisplayMode(); + bool eventFilter(QObject *obj, QEvent *event) override; void retranslateUi(); void rebuild(); void processUserInfo(const ServerInfo_User &user, bool online);