diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index fc43560ab..6fd683461 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -260,6 +260,7 @@ set(cockatrice_SOURCES src/interface/widgets/server/user/user_info_connection.cpp src/interface/widgets/server/user/user_list_manager.cpp src/interface/widgets/server/user/user_list_painter.cpp + src/interface/widgets/server/user/user_list_panel_widget.cpp src/interface/widgets/server/user/user_list_widget.cpp src/interface/widgets/settings_page/abstract_settings_page.cpp src/interface/widgets/settings_page/appearance_settings_page.cpp diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index ebe35c771..8986a9f00 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -123,7 +123,7 @@ void ThemeManager::ensureThemeDirectoryExists() } } -bool ThemeManager::isDarkMode(const QString &themeDirPath) +bool ThemeManager::isDarkMode(const QString &themeDirPath) const { ThemeConfig themeConfig = ThemeConfig::fromThemeDir(themeDirPath); if (themeConfig.colorScheme.compare("Dark", Qt::CaseInsensitive) == 0) { diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index 861ab838b..e3a40660b 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -66,7 +66,14 @@ protected: public: bool isBuiltInTheme(); - bool isDarkMode(const QString &themeDirPath); + // Explicit color scheme of the theme: theme.cfg's ColorScheme setting + // (Dark/Light), falling back to the OS color scheme when it is "System". + bool isDarkMode(const QString &themeDirPath) const; + // The resolved scheme of the currently active theme. + bool isDarkModeActive() const + { + return isDarkMode(currentThemePath); + } QStringMap &getAvailableThemes(); // Returns the path to the currently active theme directory (empty = default) QString getCurrentThemePath() const diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index 112f107d4..5d36fbcdb 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -1,6 +1,7 @@ #include "user_info_popup.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/tabs/tab_supervisor.h" #include "user_list_painter.h" @@ -22,6 +23,42 @@ #include #include +/// Qt stylesheets accept #aarrggbb, which is QColor::name(QColor::HexArgb). +static QString colorStr(const QColor &color) +{ + return color.name(QColor::HexArgb); +} + +PopupTheme PopupTheme::fromPalette(const QPalette &palette, bool dark) +{ + PopupTheme t; + t.dark = dark; + const QColor window = palette.color(QPalette::Window); + const QColor base = palette.color(QPalette::Base); + const QColor mid = palette.color(QPalette::Mid); + const QColor text = palette.color(QPalette::Text); + const QColor disabledText = palette.color(QPalette::Disabled, QPalette::Text); + const QColor highlight = palette.color(QPalette::Highlight); + + t.bg = window; + t.border = mid; + t.text = text; + t.subText = disabledText; + t.statusText = disabledText; + t.buttonBg = base; + t.buttonBorder = mid; + t.buttonHover = UserListPainter::blend(base, highlight, dark ? 0.30 : 0.12); + t.buttonPressed = UserListPainter::blend(base, highlight, dark ? 0.50 : 0.25); + t.buttonDisabled = disabledText; + t.closeBg = UserListPainter::blend(base, window, 0.5); + t.closeHover = dark ? QColor(200, 50, 50) : UserListPainter::blend(QColor(200, 50, 50), base, 0.45); + t.gamesRow = base; + t.gamesSelected = UserListPainter::blend(base, highlight, dark ? 0.45 : 0.30); + t.gamesSeparator = mid; + t.gamesSeparator.setAlpha(90); + return t; +} + // ── Compact game row delegate ───────────────────────────────────────────────── class PopupGameDelegate : public QStyledItemDelegate @@ -48,8 +85,14 @@ public: const QRect rect = option.rect; const ServerInfo_Game game = var.value(); const bool selected = option.state & QStyle::State_Selected; + const bool dark = themeManager && themeManager->isDarkModeActive(); + // The widget palette can be stale after a runtime theme change, so the + // rows are styled from the application palette (always current). + const QPalette pal = qApp->palette(); + const QColor base = pal.color(QPalette::Base); + const QColor highlight = pal.color(QPalette::Highlight); - p->fillRect(rect, selected ? QColor(35, 45, 62) : QColor(14, 18, 26)); + p->fillRect(rect, selected ? UserListPainter::blend(base, highlight, dark ? 0.45 : 0.30) : base); // State colour dot const QColor dot = game.started() ? QColor(239, 68, 68) @@ -64,7 +107,7 @@ public: QFont tf = option.font; tf.setBold(true); p->setFont(tf); - p->setPen(QColor(205, 215, 230)); + p->setPen(pal.color(QPalette::Text)); const int textX = rect.left() + 26; const int countW = 52; const int titleW = rect.width() - textX - countW - 6; @@ -74,13 +117,15 @@ public: // 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->setPen(full ? QColor(249, 115, 22) : pal.color(QPalette::Disabled, QPalette::Text)); 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)); + QColor separator = pal.color(QPalette::Mid); + separator.setAlpha(90); + p->setPen(separator); p->drawLine(rect.bottomLeft(), rect.bottomRight()); p->restore(); @@ -95,17 +140,17 @@ UserInfoHeaderWidget::UserInfoHeaderWidget(QWidget *parent) : QWidget(parent) setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } -void UserInfoHeaderWidget::setUserData(const ServerInfo_User &user, - bool online, - const QPixmap &avatar, - const QPixmap &cardArt, - const CardArtParams ¶ms) +void UserInfoHeaderWidget::setUserData(const ServerInfo_User &_user, + bool _online, + const QPixmap &_avatar, + const QPixmap &_cardArt, + const CardArtParams &_params) { - m_user = user; - m_online = online; - m_avatar = avatar; - m_cardArt = cardArt; - m_params = params; + user = _user; + online = _online; + avatar = _avatar; + cardArt = _cardArt; + params = _params; update(); } @@ -115,29 +160,37 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) 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()); + const UserLevelFlags level(user.user_level()); + const QString userName = QString::fromStdString(user.name()); + const QString privLevel = QString::fromStdString(user.privlevel()); - // Dark base - p.fillRect(rect, QColor(14, 18, 26)); + const bool dark = themeManager && themeManager->isDarkModeActive(); + const UserListPainter::Style style = UserListPainter::resolveStyle(qApp->palette(), dark); + + // Palette surface + { + QLinearGradient bg(0, 0, rect.width(), 0); + bg.setColorAt(0, style.cardStart); + bg.setColorAt(1, style.cardEnd); + p.fillRect(rect, bg); + } // ── Card art background ─────────────────────────────────────────────────── - if (!m_cardArt.isNull()) { + if (!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 mL = qRound(w * params.marginPctL); + const int mR = qRound(w * 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 double base = qMax(double(dW) / cardArt.width(), double(h) / cardArt.height()); + const double scale = base * params.zoom; + const int sW = qRound(cardArt.width() * scale); + const int sH = qRound(cardArt.height() * scale); - const QPixmap scaled = m_cardArt.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + const QPixmap scaled = 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)); + const int srcY = qBound(0, qRound((sH - h) * params.verticalOffset), qMax(0, sH - h)); QImage img = scaled.copy(srcX, srcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); { @@ -155,12 +208,14 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) p.setOpacity(1.0); } - // Bottom gradient overlay so avatar and text are always legible + // Bottom gradient overlay so avatar and text are always legible. The scrim + // is the palette's Window color so it reads naturally in either scheme. { + const QColor scrim = qApp->palette().color(QPalette::Window); 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)); + ov.setColorAt(0.0, QColor(scrim.red(), scrim.green(), scrim.blue(), 0)); + ov.setColorAt(0.55, QColor(scrim.red(), scrim.green(), scrim.blue(), 110)); + ov.setColorAt(1.0, QColor(scrim.red(), scrim.green(), scrim.blue(), 230)); p.fillRect(rect, ov); } @@ -187,20 +242,20 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) p.save(); p.setClipPath(clip); - if (!m_avatar.isNull()) { - p.drawPixmap(ar, m_avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + if (!avatar.isNull()) { + p.drawPixmap(ar, avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); } else { p.setPen(Qt::NoPen); - p.setBrush(accent.darker(200)); + p.setBrush(UserListPainter::blend(accent, style.base, dark ? 0.45 : 0.72)); p.drawEllipse(ar); const QPixmap pawn = - UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, m_user.pawn_colors(), false, privLevel); + UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, 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.setPen(QPen(online ? QColor(34, 197, 94) : style.ringOffline, 2.5)); p.setBrush(Qt::NoBrush); p.drawEllipse(QRectF(ar).adjusted(-1.25, -1.25, 1.25, 1.25)); @@ -212,7 +267,7 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) 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.setPen(online ? style.textOnline : style.textOffline); p.drawText(QRect(tx, ay, tw, AvatarSize / 2 + 4), Qt::AlignBottom | Qt::AlignLeft, QFontMetrics(nf).elidedText(userName, Qt::ElideRight, tw)); @@ -243,143 +298,173 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) 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.setBrush(UserListPainter::blend(badge.color, style.base, dark ? 0.55 : 0.78)); p.drawRoundedRect(br, 3, 3); - p.setPen(badge.color.lighter(150)); + p.setPen(dark ? UserListPainter::blend(badge.color, Qt::white, 0.5) + : UserListPainter::blend(badge.color, Qt::black, 0.35)); p.drawText(br, Qt::AlignCenter, badge.text); } } // ── UserInfoPopup ───────────────────────────────────────────────────────────── -UserInfoPopup::UserInfoPopup(TabSupervisor *ts, - AbstractClient *client, - const QMap *avatarCache, - const QMap *cardArtCache, - const QMap *cardArtParamsMap, +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) + : QFrame(parent, Qt::Tool | Qt::FramelessWindowHint), ts(_ts), client(_client), avatarCache(_avatarCache), + cardArtCache(_cardArtCache), cardArtParamsMap(_cardArtParamsMap) { setAttribute(Qt::WA_ShowWithoutActivating); setFixedWidth(PopupWidth); setFrameShape(QFrame::NoFrame); buildUi(); + + // Restyle the popup chrome when the theme or its color scheme changes. + if (themeManager) { + connect(themeManager, &ThemeManager::themeChanged, this, &UserInfoPopup::applyTheme); + } } 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); + header = new UserInfoHeaderWidget(this); + root->addWidget(header); // Action area — rebuilt per user - m_actionArea = new QWidget(this); - m_actionArea->setStyleSheet(QStringLiteral("background:#0e1218;")); - root->addWidget(m_actionArea); + actionArea = new QWidget(this); + root->addWidget(actionArea); // Thin separator - auto *sep = new QFrame(this); - sep->setFrameShape(QFrame::HLine); - sep->setStyleSheet(QStringLiteral("color:#1a2434; margin: 0 8px;")); - root->addWidget(sep); + separator = new QFrame(this); + separator->setFrameShape(QFrame::HLine); + root->addWidget(separator); // 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); + gamesLabel = new QLabel(tr("Games"), this); + gh->addWidget(gamesLabel); 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); + refreshBtn = new QPushButton(QStringLiteral("↻"), this); + refreshBtn->setFixedSize(20, 20); + refreshBtn->setFlat(true); + connect(refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames); + gh->addWidget(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); + gamesStatus = new QLabel(this); + gamesStatus->setAlignment(Qt::AlignCenter); + root->addWidget(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;}")); - m_gamesView->setContextMenuPolicy(Qt::CustomContextMenu); - connect(m_gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu); + gamesModel = new QStandardItemModel(this); + gamesView = new QListView(this); + gamesView->setModel(gamesModel); + gamesView->setItemDelegate(new PopupGameDelegate(gamesView)); + gamesView->setFrameShape(QFrame::NoFrame); + gamesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + gamesView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + gamesView->setMaximumHeight(220); + gamesView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu); - root->addWidget(m_gamesView); + root->addWidget(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); + closeBtn = new QPushButton(QStringLiteral("✕"), this); + closeBtn->setFixedSize(22, 22); + closeBtn->setFlat(true); + connect(closeBtn, &QPushButton::clicked, this, &UserInfoPopup::closeRequested); + + applyTheme(); +} + +void UserInfoPopup::applyTheme() +{ + const bool dark = themeManager && themeManager->isDarkModeActive(); + theme = PopupTheme::fromPalette(qApp->palette(), dark); + + setStyleSheet(QStringLiteral("UserInfoPopup {" + " background:%1;" + " border:1px solid %2;" + " border-radius:8px;" + "}") + .arg(colorStr(theme.bg), colorStr(theme.border))); + + actionArea->setStyleSheet(QStringLiteral("background:%1;").arg(colorStr(theme.bg))); + + separator->setStyleSheet(QStringLiteral("color:%1; margin: 0 8px;").arg(colorStr(theme.border))); + + gamesLabel->setStyleSheet(QStringLiteral("color:%1; font-size:11px; font-weight:bold; background:transparent;") + .arg(colorStr(theme.subText))); + + refreshBtn->setStyleSheet(QStringLiteral("QPushButton{color:%1;border:none;font-size:14px;background:transparent;}" + "QPushButton:hover{color:%2;}") + .arg(colorStr(theme.subText), colorStr(theme.text))); + + gamesStatus->setStyleSheet(QStringLiteral("color:%1; font-size:11px; padding:10px; background:transparent;") + .arg(colorStr(theme.statusText))); + + gamesView->setStyleSheet(QStringLiteral("QListView{background:%1;border:none;}" + "QListView::item:selected{background:%2;}") + .arg(colorStr(theme.gamesRow), colorStr(theme.gamesSelected))); + + closeBtn->setStyleSheet( + QStringLiteral("QPushButton{background:%1;color:%2;" + "border:none;border-radius:11px;font-size:10px;}" + "QPushButton:hover{color:%3;background:%4;}") + .arg(colorStr(theme.closeBg), colorStr(theme.subText), colorStr(theme.text), colorStr(theme.closeHover))); + + header->update(); } // ── Action button factory ───────────────────────────────────────────────────── -static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p) +static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p, const PopupTheme &t) { 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;" + " background:%1;color:%2;border:1px solid %3;" " 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;}")); + "QPushButton:hover{background:%4;color:%5;}" + "QPushButton:pressed{background:%6;}" + "QPushButton:disabled{color:%7;border-color:%3;}") + .arg(colorStr(t.buttonBg), colorStr(t.text), colorStr(t.buttonBorder), colorStr(t.buttonHover), + colorStr(t.text), colorStr(t.buttonPressed), colorStr(t.buttonDisabled))); 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); + delete actionArea->layout(); + const auto old = 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 auto ownLevel = UserLevelFlags(ts->getUserInfo()->user_level()); + const bool isSelf = (name == QString::fromStdString(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); + auto *grid = new QGridLayout(actionArea); grid->setContentsMargins(8, 6, 8, 6); grid->setSpacing(4); @@ -394,16 +479,16 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o }; // ── Always visible ──────────────────────────────────────────────────────── - auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), m_actionArea); + auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), actionArea, theme); 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); + auto *prof = makeBtn(tr("Profile"), tr("View user profile"), actionArea, theme); 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); + auto *games = makeBtn(tr("Games"), tr("Show this user's games"), actionArea, theme); games->setEnabled(!isSelf && online); connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); }); add(games); @@ -411,20 +496,20 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o // ── Buddy / ignore (registered users only) ──────────────────────────────── if (!isSelf && isReg) { if (isBuddy) { - auto *b = makeBtn(tr("− Buddy"), tr("Remove from buddy list"), m_actionArea); + auto *b = makeBtn(tr("− Buddy"), tr("Remove from buddy list"), actionArea, theme); 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); + auto *b = makeBtn(tr("+ Buddy"), tr("Add to buddy list"), actionArea, theme); 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); + auto *b = makeBtn(tr("− Ignore"), tr("Remove from ignore list"), actionArea, theme); 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); + auto *b = makeBtn(tr("+ Ignore"), tr("Add to ignore list"), actionArea, theme); connect(b, &QPushButton::clicked, this, [this, name] { emit addIgnoreRequested(name); }); add(b); } @@ -437,10 +522,10 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o 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); + auto *ban = makeBtn(tr("Ban"), tr("Ban from server"), actionArea, theme); + auto *warn = makeBtn(tr("Warn"), tr("Warn user"), actionArea, theme); + auto *bLog = makeBtn(tr("Ban log"), tr("View ban history"), actionArea, theme); + auto *wLog = makeBtn(tr("Warn log"), tr("View warning history"), actionArea, theme); 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); }); @@ -453,31 +538,31 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o // ── Admin actions ───────────────────────────────────────────────────────── if (!isSelf && isAdmin) { - auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), m_actionArea); + auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), actionArea, theme); 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); + auto *b = makeBtn(tr("− Mod"), tr("Demote from moderator"), actionArea, theme); 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); + auto *b = makeBtn(tr("+ Mod"), tr("Promote to moderator"), actionArea, theme); 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); + auto *b = makeBtn(tr("− Judge"), tr("Demote from judge"), actionArea, theme); 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); + auto *b = makeBtn(tr("+ Judge"), tr("Promote to judge"), actionArea, theme); connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToJudgeRequested(name); }); add(b); } } - m_actionArea->adjustSize(); + actionArea->adjustSize(); } void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) @@ -488,7 +573,7 @@ void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool on void UserInfoPopup::onGamesContextMenu(const QPoint &pos) { - const QModelIndex idx = m_gamesView->indexAt(pos); + const QModelIndex idx = gamesView->indexAt(pos); if (!idx.isValid()) { return; } @@ -501,8 +586,9 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos) QMenu menu(this); menu.setStyleSheet( - QStringLiteral("QMenu{background:#12182a;color:#c8d8ec;border:1px solid #1e2838;border-radius:4px;}" - "QMenu::item:selected{background:#223050;}")); + QStringLiteral("QMenu{background:%1;color:%2;border:1px solid %3;border-radius:4px;}" + "QMenu::item:selected{background:%4;}") + .arg(colorStr(theme.bg), colorStr(theme.text), colorStr(theme.border), colorStr(theme.buttonHover))); const bool canJoin = !game.started() && game.player_count() < game.max_players(); QAction *join = menu.addAction(tr("Join game")); @@ -513,7 +599,7 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos) spec = menu.addAction(tr("Spectate")); } - const QAction *chosen = menu.exec(m_gamesView->viewport()->mapToGlobal(pos)); + const QAction *chosen = menu.exec(gamesView->viewport()->mapToGlobal(pos)); if (!chosen) { return; } @@ -529,17 +615,17 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos) void UserInfoPopup::refreshHeader() { - if (m_currentUser.isEmpty()) { + if (currentUser.isEmpty()) { return; } - const QPixmap avatar = m_avatarCache ? m_avatarCache->value(m_currentUser) : QPixmap{}; - const CardArtParams params = (m_cardArtParamsMap && m_cardArtParamsMap->contains(m_currentUser)) - ? m_cardArtParamsMap->value(m_currentUser) + const QPixmap avatar = avatarCache ? avatarCache->value(currentUser) : QPixmap{}; + const CardArtParams params = (cardArtParamsMap && cardArtParamsMap->contains(currentUser)) + ? cardArtParamsMap->value(currentUser) : CardArtParams{}; - const QString artKey = m_currentUser + u'|' + params.cardName + u'|' + params.cardProviderId; - const QPixmap cardArt = (m_cardArtCache && !params.cardName.isEmpty()) ? m_cardArtCache->value(artKey) : QPixmap{}; - m_header->setUserData(m_currentUserInfo, m_currentOnline, avatar, cardArt, params); + const QString artKey = currentUser + u'|' + params.cardName + u'|' + params.cardProviderId; + const QPixmap cardArt = (cardArtCache && !params.cardName.isEmpty()) ? cardArtCache->value(artKey) : QPixmap{}; + header->setUserData(currentUserInfo, currentOnline, avatar, cardArt, params); } void UserInfoPopup::showForUser(const QString &userName, @@ -548,9 +634,9 @@ void UserInfoPopup::showForUser(const QString &userName, bool isBuddy, bool isIgnored) { - m_currentUser = userName; - m_currentUserInfo = userInfo; - m_currentOnline = online; + currentUser = userName; + currentUserInfo = userInfo; + currentOnline = online; // Header refreshHeader(); @@ -559,14 +645,14 @@ void UserInfoPopup::showForUser(const QString &userName, rebuildActionButtons(userInfo, online, isBuddy, isIgnored); // Games list reset - m_gamesModel->clear(); - m_gamesView->hide(); - m_gamesStatus->setText(tr("Loading games…")); - m_gamesStatus->show(); + gamesModel->clear(); + gamesView->hide(); + gamesStatus->setText(tr("Loading games…")); + gamesStatus->show(); // Close button — top-right corner, above everything - m_closeBtn->move(PopupWidth - m_closeBtn->width() - 6, 6); - m_closeBtn->raise(); + closeBtn->move(PopupWidth - closeBtn->width() - 6, 6); + closeBtn->raise(); adjustSize(); fetchGames(); @@ -576,40 +662,40 @@ void UserInfoPopup::showForUser(const QString &userName, void UserInfoPopup::fetchGames() { - if (!m_client || m_currentUser.isEmpty()) { + if (!client || currentUser.isEmpty()) { return; } Command_GetGamesOfUser cmd; - cmd.set_user_name(m_currentUser.toStdString()); + cmd.set_user_name(currentUser.toStdString()); - const QString snapshot = m_currentUser; - PendingCommand *pend = m_client->prepareSessionCommand(cmd); + const QString snapshot = currentUser; + PendingCommand *pend = client->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, [this, snapshot](const Response &r) { onGamesReceived(r, snapshot); }); - m_client->sendCommand(pend); + client->sendCommand(pend); } void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) { - if (forUser != m_currentUser) { + if (forUser != currentUser) { return; // stale response — different user showing now } - m_gamesModel->clear(); + gamesModel->clear(); if (r.response_code() != Response::RespOk) { - m_gamesStatus->setText(tr("Could not load games.")); - m_gamesStatus->show(); - m_gamesView->hide(); + gamesStatus->setText(tr("Could not load games.")); + gamesStatus->show(); + 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(); + gamesStatus->setText(tr("No active games.")); + gamesStatus->show(); + gamesView->hide(); return; } @@ -617,29 +703,29 @@ void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) auto *item = new QStandardItem; item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData); item->setEditable(false); - m_gamesModel->appendRow(item); + gamesModel->appendRow(item); } - m_gamesStatus->hide(); - m_gamesView->show(); + gamesStatus->hide(); + gamesView->show(); // Fit exactly to the number of visible rows, scroll when more than 5 constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint constexpr int maxRows = 5; - const int count = m_gamesModel->rowCount(); + const int count = gamesModel->rowCount(); const int visible = qMin(count, maxRows); - m_gamesView->setFixedHeight(visible * rowH + 2); - m_gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); + gamesView->setFixedHeight(visible * rowH + 2); + gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); adjustSize(); } void UserInfoPopup::refreshGames() { - m_gamesModel->clear(); - m_gamesView->hide(); - m_gamesStatus->setText(tr("Loading games…")); - m_gamesStatus->show(); + gamesModel->clear(); + gamesView->hide(); + gamesStatus->setText(tr("Loading games…")); + gamesStatus->show(); fetchGames(); } diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.h b/cockatrice/src/interface/widgets/server/user/user_info_popup.h index c634511e1..851223c87 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -26,6 +26,35 @@ namespace PopupRoles constexpr int GameData = Qt::UserRole + 10; } +// Popup theme + +/** + * Palette-derived colors for the popup chrome. Both color schemes read from + * the active QPalette so custom palettes are respected. @c dark only tunes the + * blend strengths. + */ +struct PopupTheme +{ + bool dark = false; + QColor bg; + QColor border; + QColor text; + QColor subText; + QColor buttonBg; + QColor buttonBorder; + QColor buttonHover; + QColor buttonPressed; + QColor buttonDisabled; + QColor closeBg; + QColor closeHover; + QColor gamesRow; + QColor gamesSelected; + QColor gamesSeparator; + QColor statusText; + + static PopupTheme fromPalette(const QPalette &palette, bool dark); +}; + // ── Header widget ───────────────────────────────────────────────────────────── /** @@ -51,21 +80,21 @@ class UserInfoHeaderWidget : public QWidget public: explicit UserInfoHeaderWidget(QWidget *parent = nullptr); - void setUserData(const ServerInfo_User &user, - bool online, - const QPixmap &avatar, - const QPixmap &cardArt, - const CardArtParams ¶ms); + void setUserData(const ServerInfo_User &_user, + bool _online, + const QPixmap &_avatar, + const QPixmap &_cardArt, + const CardArtParams &_params); protected: void paintEvent(QPaintEvent *e) override; private: - ServerInfo_User m_user; - bool m_online = false; - QPixmap m_avatar; - QPixmap m_cardArt; - CardArtParams m_params; + ServerInfo_User user; + bool online = false; + QPixmap avatar; + QPixmap cardArt; + CardArtParams params; }; // ── Main popup ──────────────────────────────────────────────────────────────── @@ -93,11 +122,11 @@ class UserInfoPopup : public QFrame static constexpr int PopupWidth = 316; public: - explicit UserInfoPopup(TabSupervisor *tabSupervisor, - AbstractClient *client, - const QMap *avatarCache, - const QMap *cardArtCache, - const QMap *cardArtParamsMap, + explicit UserInfoPopup(TabSupervisor *_ts, + AbstractClient *_client, + const QMap *_avatarCache, + const QMap *_cardArtCache, + const QMap *_cardArtParamsMap, QWidget *parent); /** @@ -108,9 +137,9 @@ public: showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); void fetchGames(); - [[nodiscard]] QString currentUser() const + [[nodiscard]] QString getCurrentUser() const { - return m_currentUser; + return currentUser; } /** Called when buddy/ignore status changes externally while popup is open. */ @@ -156,25 +185,30 @@ private slots: private: void buildUi(); + void applyTheme(); 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; + TabSupervisor *ts; + AbstractClient *client; + const QMap *avatarCache; + const QMap *cardArtCache; + const QMap *cardArtParamsMap; - QString m_currentUser; - ServerInfo_User m_currentUserInfo; - bool m_currentOnline = false; + PopupTheme theme; - UserInfoHeaderWidget *m_header; - QWidget *m_actionArea; ///< rebuilt per user - QListView *m_gamesView; - QStandardItemModel *m_gamesModel; - QLabel *m_gamesStatus; - QPushButton *m_closeBtn; - QPushButton *m_refreshBtn; + QString currentUser; + ServerInfo_User currentUserInfo; + bool currentOnline = false; + + UserInfoHeaderWidget *header; + QWidget *actionArea; ///< rebuilt per user + QLabel *gamesLabel; + QFrame *separator; + QListView *gamesView; + QStandardItemModel *gamesModel; + QLabel *gamesStatus; + QPushButton *closeBtn; + QPushButton *refreshBtn; }; #endif // COCKATRICE_USER_INFO_POPUP_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp index 8891ff268..5a4723065 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp @@ -3,6 +3,7 @@ #include "../../interface/pixel_map_generator.h" #include +#include #include #include #include @@ -19,6 +20,29 @@ QSize UserListPainter::sizeHint() return QSize(0, RowHeight); } +UserListPainter::Style UserListPainter::resolveStyle(const QPalette &palette, bool dark) +{ + Style style; + style.dark = dark; + const QColor base = palette.color(QPalette::Base); + const QColor alt = palette.color(QPalette::AlternateBase); + style.cardStart = base; + style.cardEnd = (alt != base) ? alt : palette.color(QPalette::Midlight); + style.base = base; + style.textOnline = palette.color(QPalette::Text); + style.textOffline = palette.color(QPalette::Disabled, QPalette::Text); + style.ringOffline = palette.color(QPalette::Disabled, QPalette::Text); + style.dropShadow = dark; + return style; +} + +QColor UserListPainter::blend(const QColor &a, const QColor &b, qreal t) +{ + const qreal u = 1.0 - t; + return QColor(qRound(a.red() * u + b.red() * t), qRound(a.green() * u + b.green() * t), + qRound(a.blue() * u + b.blue() * t), qRound(a.alpha() * u + b.alpha() * t)); +} + QColor UserListPainter::getAccentColor(const UserLevelFlags &userLevel, bool online) { QColor accentColor; @@ -59,18 +83,41 @@ int UserListPainter::getCardRight(const QStyleOptionViewItem &option, const QRec void UserListPainter::drawBackground(QPainter *painter, const QRectF &cardRect, const QColor &accentColor, - bool selected) + bool selected, + const Style &style, + bool hasRole) { QLinearGradient bg(cardRect.topLeft(), cardRect.topRight()); - bg.setColorAt(0, selected ? accentColor.darker(130) : accentColor.darker(320)); - bg.setColorAt(1, selected ? QColor(40, 48, 60) : QColor(18, 22, 30)); + if (style.dark) { + // Dark mode darkens the role color to fit the dark surface and fades + // it into the deep navy surface on the right. The text drop shadow + // keeps the username legible over the colored edge. + bg.setColorAt(0, selected ? accentColor.darker(130) : accentColor.darker(320)); + bg.setColorAt(1, selected ? QColor(40, 48, 60) : QColor(18, 22, 30)); + } else if (hasRole) { + // Light mode pegs the role color on the left at near full strength + // and fades it into the white surface on the right. The tint stays + // bright enough that the dark text remains legible without a shadow. + bg.setColorAt(0, blend(style.cardStart, accentColor, selected ? 0.75 : 0.65)); + bg.setColorAt(1, blend(style.cardEnd, accentColor, selected ? 0.18 : 0.10)); + } else { + // Regular users are the light theme's neutral paper cards. A flat + // warm card fill (the normal row surface, slightly deepened) keeps + // every row clearly visible without borrowing a role color. Selection + // shifts the fill toward a soft slate so the highlight still reads. + const QColor paper = style.cardEnd.darker(108); + bg.setColorAt(0, blend(paper, accentColor, selected ? 0.35 : 0.0)); + bg.setColorAt(1, blend(paper, accentColor, selected ? 0.25 : 0.0)); + } painter->setPen(Qt::NoPen); painter->setBrush(bg); painter->drawRoundedRect(cardRect, 6, 6); - painter->setBrush(accentColor); - painter->drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); + if (style.dark || hasRole || selected) { + painter->setBrush(accentColor); + painter->drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); + } } static QString makeKey(const QString &user, const QString &card, const QString &providerId) @@ -163,7 +210,8 @@ void UserListPainter::drawAvatar(QPainter *painter, const UserLevelFlags &userLevel, const ServerInfo_User &userInfo, const QString &privLevel, - const QMap *avatarCache) + const QMap *avatarCache, + const Style &style) { QPainterPath clipPath; clipPath.addEllipse(avatarRect); @@ -183,7 +231,7 @@ void UserListPainter::drawAvatar(QPainter *painter, } if (!drewAvatar) { - painter->setBrush(accentColor.darker(200)); + painter->setBrush(blend(accentColor, style.base, style.dark ? 0.45 : 0.72)); painter->setPen(Qt::NoPen); painter->drawEllipse(avatarRect); @@ -196,9 +244,9 @@ void UserListPainter::drawAvatar(QPainter *painter, painter->restore(); } -void UserListPainter::drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online) +void UserListPainter::drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online, const Style &style) { - const QColor statusColor = online ? QColor(34, 197, 94) : QColor(70, 80, 95); + const QColor statusColor = online ? QColor(34, 197, 94) : style.ringOffline; painter->setPen(QPen(statusColor, 2)); painter->setBrush(Qt::NoBrush); @@ -212,7 +260,7 @@ void UserListPainter::drawUserName(QPainter *painter, int textX, const QString &userName, bool online, - bool selected) + const Style &style) { QFont nameFont = option.font; nameFont.setBold(true); @@ -221,10 +269,12 @@ void UserListPainter::drawUserName(QPainter *painter, const QRect nameRect(textX, rect.top() + 8, cardRight - textX - 10, 20); const QString elidedName = QFontMetrics(nameFont).elidedText(userName, Qt::ElideRight, cardRight - textX - 10); - painter->setPen(QColor(0, 0, 0, 200)); - painter->drawText(nameRect.translated(1, 1), Qt::AlignVCenter | Qt::AlignLeft, elidedName); + if (style.dropShadow) { + painter->setPen(QColor(0, 0, 0, 200)); + painter->drawText(nameRect.translated(1, 1), Qt::AlignVCenter | Qt::AlignLeft, elidedName); + } - painter->setPen(online ? (selected ? Qt::white : QColor(226, 232, 240)) : QColor(90, 100, 115)); + painter->setPen(online ? style.textOnline : style.textOffline); painter->drawText(nameRect, Qt::AlignVCenter | Qt::AlignLeft, elidedName); } @@ -262,7 +312,8 @@ void UserListPainter::drawBadges(QPainter *painter, const QRect &rect, int cardRight, const QList &badges, - bool online) + bool online, + const Style &style) { if (badges.isEmpty()) { return; @@ -284,15 +335,17 @@ void UserListPainter::drawBadges(QPainter *painter, int bx = cardRight - 6 - totalBadgeW; for (const Badge &b : badges) { - const QColor col = online ? b.color : b.color.darker(180); + const QColor col = online ? b.color : blend(b.color, style.base, 0.55); + const QColor surface = blend(col, style.base, style.dark ? 0.55 : 0.78); + const QColor text = style.dark ? blend(col, Qt::white, 0.5) : blend(col, Qt::black, 0.35); const int bw = fm.horizontalAdvance(b.text) + 8; const QRect br(bx, rect.top() + 44, bw, 13); painter->setPen(Qt::NoPen); - painter->setBrush(col.darker(online ? 160 : 220)); + painter->setBrush(surface); painter->drawRoundedRect(br, 3, 3); - painter->setPen(col.lighter(online ? 160 : 100)); + painter->setPen(text); painter->drawText(br, Qt::AlignCenter, b.text); bx += bw + 4; @@ -305,11 +358,19 @@ void UserListPainter::paint(QPainter *painter, const ServerInfo_User &userInfo, const QMap *avatarCache, const QMap *cardArtCache, - const QMap *cardArtParamsMap) + const QMap *cardArtParamsMap, + bool dark) { painter->save(); painter->setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + // The delegate supplies the application palette in option.palette, which + // always reflects the active theme. The widget palette can be stale after + // a runtime theme change, so it is only used as a defensive fallback. + const QPalette pal = + option.palette == QPalette() ? (option.widget ? option.widget->palette() : qApp->palette()) : option.palette; + const Style style = resolveStyle(pal, dark); + const QRect rect = option.rect; const bool online = index.data(Qt::UserRole + 1).toBool(); const bool selected = option.state & QStyle::State_Selected; @@ -317,6 +378,9 @@ void UserListPainter::paint(QPainter *painter, const QString userName = QString::fromStdString(userInfo.name()); const QString privLevel = QString::fromStdString(userInfo.privlevel()); const QColor accentColor = getAccentColor(userLevel, online); + const bool hasRole = userLevel.testFlag(ServerInfo_User::IsAdmin) || + userLevel.testFlag(ServerInfo_User::IsModerator) || + userLevel.testFlag(ServerInfo_User::IsJudge); const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); const int cardRight = getCardRight(option, rect); @@ -324,19 +388,19 @@ void UserListPainter::paint(QPainter *painter, ? cardArtParamsMap->value(userName) : CardArtParams{}; - drawBackground(painter, cardRect, accentColor, selected); + drawBackground(painter, cardRect, accentColor, selected, style, hasRole); drawCardArt(painter, rect, cardRight, userName, cardArtCache, params); const QRect avatarRect = getAvatarRect(rect); - drawAvatar(painter, avatarRect, userName, accentColor, userLevel, userInfo, privLevel, avatarCache); - drawStatusRing(painter, avatarRect, online); + drawAvatar(painter, avatarRect, userName, accentColor, userLevel, userInfo, privLevel, avatarCache, style); + drawStatusRing(painter, avatarRect, online, style); const int textX = avatarRect.right() + TextSpacing; - drawUserName(painter, option, rect, cardRight, textX, userName, online, selected); + drawUserName(painter, option, rect, cardRight, textX, userName, online, style); drawCountryFlag(painter, rect, textX, userInfo); const QList badges = buildBadges(userLevel, privLevel); - drawBadges(painter, option, rect, cardRight, badges, online); + drawBadges(painter, option, rect, cardRight, badges, online, style); painter->restore(); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.h b/cockatrice/src/interface/widgets/server/user/user_list_painter.h index 28cab9675..352a01f6b 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -28,13 +29,36 @@ struct CardArtParams class UserListPainter { public: + /** + * Palette-derived surface colors for the current color scheme. Both the + * light and the dark scheme read from the active QPalette so custom + * palettes are respected. @c dark only tunes the blend strengths (and + * whether the name text keeps its drop shadow). + */ + struct Style + { + bool dark = true; + QColor cardStart; ///< row fill, left edge (normal) + QColor cardEnd; ///< row fill, right edge (normal) + QColor base; ///< lightest surface, used for blending accent hues + QColor textOnline; + QColor textOffline; + QColor ringOffline; + bool dropShadow = false; + }; + + static Style resolveStyle(const QPalette &palette, bool dark); + /// Linear interpolation: @p t = 0 returns @p a, @p t = 1 returns @p b. + static QColor blend(const QColor &a, const QColor &b, qreal t); + static void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index, const ServerInfo_User &userInfo, const QMap *avatarCache, const QMap *cardArtCache, - const QMap *cardArtParamsMap); + const QMap *cardArtParamsMap, + bool dark); static QSize sizeHint(); @@ -55,7 +79,12 @@ private: static QColor getAccentColor(const UserLevelFlags &userLevel, bool online); static int getCardRight(const QStyleOptionViewItem &option, const QRect &rect); - static void drawBackground(QPainter *painter, const QRectF &cardRect, const QColor &accentColor, bool selected); + static void drawBackground(QPainter *painter, + const QRectF &cardRect, + const QColor &accentColor, + bool selected, + const Style &style, + bool hasRole); static QRect getAvatarRect(const QRect &rect); static void drawAvatar(QPainter *painter, const QRect &avatarRect, @@ -64,8 +93,9 @@ private: const UserLevelFlags &userLevel, const ServerInfo_User &userInfo, const QString &privLevel, - const QMap *avatarCache); - static void drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online); + const QMap *avatarCache, + const Style &style); + static void drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online, const Style &style); static void drawUserName(QPainter *painter, const QStyleOptionViewItem &option, const QRect &rect, @@ -73,7 +103,7 @@ private: int textX, const QString &userName, bool online, - bool selected); + const Style &style); static void drawCountryFlag(QPainter *painter, const QRect &rect, int textX, const ServerInfo_User &userInfo); static QList buildBadges(const UserLevelFlags &userLevel, const QString &privLevel); static void drawBadges(QPainter *painter, @@ -81,7 +111,8 @@ private: const QRect &rect, int cardRight, const QList &badges, - bool online); + bool online, + const Style &style); }; -#endif // COCKATRICE_USER_LIST_PAINTER_H \ No newline at end of file +#endif // COCKATRICE_USER_LIST_PAINTER_H diff --git a/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp new file mode 100644 index 000000000..937058024 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp @@ -0,0 +1,88 @@ +#include "user_list_panel_widget.h" + +#include "../../../../client/settings/cache_settings.h" +#include "user_list_manager.h" +#include "user_list_widget.h" + +#include +#include +#include +#include + +namespace +{ +// The persisted section keys are the serialization contract with the user's +// settings file, so the values must stay stable across versions. +QString sectionKey(UserListWidget::Section section) +{ + switch (section) { + case UserListWidget::Section::Buddy: + return QStringLiteral("buddy"); + case UserListWidget::Section::Online: + return QStringLiteral("online"); + case UserListWidget::Section::Ignore: + return QStringLiteral("ignore"); + } + return {}; +} +} // namespace + +UserListPanelWidget::UserListPanelWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, QWidget *parent) + : QWidget(parent) +{ + auto *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout->setSpacing(2); + + searchBar = new QLineEdit(this); + searchBar->setClearButtonEnabled(true); + mainLayout->addWidget(searchBar); + + userList = new UserListWidget(_tabSupervisor, _client, UserListWidget::RoomList, this); + userList->setSectioned( + {UserListWidget::Section::Buddy, UserListWidget::Section::Online, UserListWidget::Section::Ignore}); + mainLayout->addWidget(userList, 1); + + connect(searchBar, &QLineEdit::textChanged, userList, &UserListWidget::setFilterText); + + connect(userList, &UserListWidget::sectionExpanded, this, &UserListPanelWidget::persistExpandedSections); + connect(userList, &UserListWidget::openMessageDialog, this, &UserListPanelWidget::openMessageDialog); + + // Restore the persisted expansion state, then apply it to the tree. + const QStringList expandedSections = SettingsCache::instance().userInterface().getUserListExpandedSections(); + for (const UserListWidget::Section section : userList->getSectionIds()) { + userList->setSectionExpanded(section, expandedSections.contains(sectionKey(section))); + } + + retranslateUi(); +} + +void UserListPanelWidget::bind(UserListManager *manager) +{ + userList->bind(manager); +} + +void UserListPanelWidget::persistExpandedSections(UserListWidget::Section section, bool expanded) +{ + const QString key = sectionKey(section); + QStringList expandedSections = SettingsCache::instance().userInterface().getUserListExpandedSections(); + if (expanded) { + if (!expandedSections.contains(key)) { + expandedSections.append(key); + } + } else { + expandedSections.removeAll(key); + } + SettingsCache::instance().userInterface().setUserListExpandedSections(expandedSections); +} + +void UserListPanelWidget::retranslateUi() +{ + searchBar->setPlaceholderText(tr("Search users...")); + userList->retranslateUi(); +} + +UserListWidget *UserListPanelWidget::getUserList() const +{ + return userList; +} diff --git a/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.h new file mode 100644 index 000000000..7ae14dfcf --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.h @@ -0,0 +1,43 @@ +/** + * @file user_list_panel_widget.h + * @ingroup Lobby + */ + +#ifndef COCKATRICE_USER_LIST_PANEL_WIDGET_H +#define COCKATRICE_USER_LIST_PANEL_WIDGET_H + +#include "user_list_widget.h" + +#include + +class AbstractClient; +class QLineEdit; +class TabSupervisor; +class UserListManager; + +/** + * A unified user list: a search bar above a single tree whose section headers + * (buddy, online, ignored) are inline dividers. The tree owns the scrolling. + */ +class UserListPanelWidget : public QWidget +{ + Q_OBJECT + +public: + explicit UserListPanelWidget(TabSupervisor *tabSupervisor, AbstractClient *client, QWidget *parent = nullptr); + void bind(UserListManager *manager); + void retranslateUi(); + + [[nodiscard]] UserListWidget *getUserList() const; + +signals: + void openMessageDialog(const QString &userName, bool focus); + +private: + void persistExpandedSections(UserListWidget::Section section, bool expanded); + + QLineEdit *searchBar = nullptr; + UserListWidget *userList = nullptr; +}; + +#endif // COCKATRICE_USER_LIST_PANEL_WIDGET_H 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 3ad357dd7..7a82b0c76 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -3,6 +3,7 @@ #include "../../../../client/settings/cache_settings.h" #include "../../../card_picture_loader/card_picture_loader.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/tabs/tab_account.h" #include "../../interface/widgets/tabs/tab_supervisor.h" #include "../game_selector.h" @@ -11,11 +12,17 @@ #include #include +#include +#include +#include +#include #include #include #include +#include #include #include +#include #include #include #include @@ -24,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -324,11 +332,15 @@ constexpr int Online = Qt::UserRole + 1; constexpr int UserInfo = Qt::UserRole + 2; } // namespace UserListRoles -UserListItemDelegate::UserListItemDelegate(QObject *const parent, +// Divider items (section headers) in sectioned mode are distinguished from user +// rows (UserListTWI, which uses QTreeWidgetItem::Type) by this item type. +constexpr int SectionItemType = QTreeWidgetItem::UserType + 1; + +UserListItemDelegate::UserListItemDelegate(QTreeWidget *tree, const QMap *avatarCache, const QMap *cardArtCache, const QMap *cardArtParamsMap) - : QStyledItemDelegate(parent), avatarCache(avatarCache), cardArtCache(cardArtCache), + : QStyledItemDelegate(tree), tree(tree), avatarCache(avatarCache), cardArtCache(cardArtCache), cardArtParamsMap(cardArtParamsMap) { } @@ -353,25 +365,129 @@ QSize UserListItemDelegate::sizeHint(const QStyleOptionViewItem &option, const Q if (!SettingsCache::instance().appearance().getStyleUserList()) { return QStyledItemDelegate::sizeHint(option, index); } + if (!index.data(UserListRoles::UserInfo).isValid()) { + return QStyledItemDelegate::sizeHint(option, index); // section dividers stay compact + } return UserListPainter::sizeHint(); } void UserListItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - if (!SettingsCache::instance().appearance().getStyleUserList()) { - QStyledItemDelegate::paint(painter, option, index); + const bool styled = SettingsCache::instance().appearance().getStyleUserList(); + // UserInfo/Online are stored on column 0 only. The name lives on column 2, + // so resolve the user data against column 0 no matter which cell is painted. + const QModelIndex userIndex = index.siblingAtColumn(0).isValid() ? index.siblingAtColumn(0) : index; + const QVariant var = userIndex.data(UserListRoles::UserInfo); + + if (styled && var.isValid()) { + // Styled card rows: the tree's cached palette can be stale after a + // runtime theme change, so paint from the application palette (always + // current) instead of option.palette (frozen at the last style switch). + QStyleOptionViewItem opt = option; + opt.palette = qApp->palette(); + + UserListPainter::paint(painter, opt, index, var.value(), avatarCache, cardArtCache, + cardArtParamsMap, themeManager && themeManager->isDarkModeActive()); return; } - const QVariant var = index.data(UserListRoles::UserInfo); + // Unstyled rows and section dividers are painted manually so every color + // is derived from the current application palette at paint time. The widget + // palette, the view's alternation, and the stored brushes (one per item) + // all go stale after a runtime theme change. + const QPalette appPal = qApp->palette(); + const bool selected = option.state & QStyle::State_Selected; + const bool hovered = option.state & QStyle::State_MouseOver; - if (!var.isValid()) { - QStyledItemDelegate::paint(painter, option, index); - return; + // Row background: selection, hover, zebra striping, plain base. + QColor bg = appPal.color(QPalette::Base); + if (selected) { + bg = appPal.color(QPalette::Highlight); + } else if (hovered) { + bg = UserListPainter::blend(appPal.color(QPalette::Base), appPal.color(QPalette::Highlight), 0.12); + } else if (var.isValid()) { + // Zebra alternation per section. The dividers are top level rows too, + // so the view's own alternation would drift across sections. Count the + // visible user rows back to the previous divider within the same parent + // (sectioned mode stores users as children of the dividers, flat mode + // as top level rows), so the stripe restarts at every divider and at + // the tree top. Hidden filter matches are skipped the same way the view + // skips them, so adjacent visible rows always alternate. + int usersSinceDivider = 0; + const QModelIndex parent = userIndex.parent(); + for (int r = userIndex.row() - 1; r >= 0; --r) { + if (tree->isRowHidden(r, parent)) { + continue; + } + const QModelIndex above = userIndex.model()->index(r, 0, parent); + if (above.isValid() && above.data(UserListRoles::UserInfo).isValid()) { + ++usersSinceDivider; + } else { + break; + } + } + if (usersSinceDivider % 2 == 1) { + bg = appPal.color(QPalette::AlternateBase); + } + } + // Paint the row background. In the column 0 pass the fill spans the full + // viewport width so stripes, hover and selection cover the whole row (the + // name column is content sized in unstyled mode). Later column passes fill + // only their own cell, which is the same color and cannot cover the icons. + QRect bgRect = option.rect; + if (index.column() == 0) { + bgRect = QRect(0, option.rect.top(), tree->viewport()->width(), option.rect.height()); + } + painter->fillRect(bgRect, bg); + + // Text color. + QColor fg = appPal.color(QPalette::Text); + if (selected) { + fg = appPal.color(QPalette::HighlightedText); + } else if (!var.isValid()) { + // Section divider: muted application text color. + fg = appPal.color(QPalette::WindowText); + fg.setAlpha(170); + } else if (index.column() == 2) { + // Name column: online/offline color recomputed at paint time instead + // of trusting the brush stored at login time. + QTreeWidgetItem *item = tree->itemFromIndex(index); + const bool online = item && item->data(0, UserListRoles::Online).toBool(); + if (online) { + fg = appPal.color(QPalette::WindowText); + } else { + fg = (themeManager && themeManager->isDarkModeActive()) + ? QColor(Qt::gray) + : UserListPainter::blend(appPal.color(QPalette::Text), appPal.color(QPalette::Mid), 0.5); + } } - UserListPainter::paint(painter, option, index, var.value(), avatarCache, cardArtCache, - cardArtParamsMap); + // Icon (level badge in column 0, country flag in column 1). + QRect textRect = option.rect; + const QIcon icon = index.data(Qt::DecorationRole).value(); + if (!icon.isNull()) { + const QSize iconSize = icon.actualSize(QSize(18, 18)); + const QRect iconRect(option.rect.left() + 2, option.rect.center().y() - iconSize.height() / 2, iconSize.width(), + iconSize.height()); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + 4); + } + + // Text (name column / divider title), elided to the row width. + painter->save(); + painter->setPen(fg); + const QFont itemFont = index.data(Qt::FontRole).value(); + painter->setFont(itemFont.isCopyOf(QFont()) ? option.font : itemFont); + const QString text = index.data(Qt::DisplayRole).toString(); + const QString elided = painter->fontMetrics().elidedText(text, Qt::ElideRight, textRect.width() - 4); + painter->drawText(textRect.adjusted(2, 0, -2, 0), Qt::AlignLeft | Qt::AlignVCenter, elided); + painter->restore(); + + // Focus indicator for the current item. + if (option.state & QStyle::State_HasFocus) { + painter->setPen(appPal.color(QPalette::Highlight)); + painter->drawRect(option.rect.adjusted(0, 0, -1, -1)); + } } UserListTWI::UserListTWI(const ServerInfo_User &_userInfo) : QTreeWidgetItem(Type) @@ -395,8 +511,10 @@ void UserListTWI::setUserInfo(const ServerInfo_User &_userInfo) void UserListTWI::setOnline(bool online) { + // Only the online state is stored here: the delegate derives the + // online/offline text color at paint time from the current application + // palette, so no brush is cached (it would go stale on theme change). setData(0, UserListRoles::Online, online); - setData(2, Qt::ForegroundRole, online ? qApp->palette().brush(QPalette::WindowText) : QBrush(Qt::gray)); } /** @@ -465,9 +583,6 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, avatarProvider = new UserAvatarProvider(client, this); cardArtProvider = new UserCardArtProvider(this); - itemDelegate = - new UserListItemDelegate(this, &avatarProvider->cache(), &cardArtProvider->cache(), &cardArtParamsMap); - userContextMenu = new UserContextMenu(tabSupervisor, this); connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &UserListWidget::openMessageDialog); @@ -478,6 +593,8 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->setHeaderHidden(true); userTree->setRootIsDecorated(false); userTree->setIconSize(QSize(20, 18)); + itemDelegate = + new UserListItemDelegate(userTree, &avatarProvider->cache(), &cardArtProvider->cache(), &cardArtParamsMap); userTree->setItemDelegate(itemDelegate); userTree->setAlternatingRowColors(true); userTree->hideColumn(1); @@ -488,28 +605,40 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, 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 + 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); + userInfoPopup->hide(); + userInfoPopup->setWindowOpacity(0.0); + userInfoPopup->installEventFilter(this); - 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); + showPopupTimer = new QTimer(this); + showPopupTimer->setSingleShot(true); + showPopupTimer->setInterval(280); + connect(showPopupTimer, &QTimer::timeout, this, [this] { + if (hoveredUser.isEmpty()) { + return; + } + // Resolve the row under the cursor again. In sectioned mode a user can + // own several rows (online + buddy), so the popup must anchor to the + // exact hovered row instead of a lookup by name. + const QPoint viewportPos = userTree->viewport()->mapFromGlobal(QCursor::pos()); + QTreeWidgetItem *item = userTree->itemAt(viewportPos); + if (item && item->type() == QTreeWidgetItem::Type && + QString::fromStdString(static_cast(item)->getUserInfo().name()) == hoveredUser) { + showPopupForUser(static_cast(item)); } }); - 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()) { + hidePopupTimer = new QTimer(this); + hidePopupTimer->setSingleShot(true); + hidePopupTimer->setInterval(160); + connect(hidePopupTimer, &QTimer::timeout, this, [this] { + // The hover ends when the cursor leaves the user row. Empty list + // space, a section divider and anything outside the tree all close + // the popup, while the popup itself keeps it alive. + if (!popupPinned && !userInfoPopup->underMouse() && (hoveredUser.isEmpty() || !userTree->underMouse())) { hidePopup(); } }); @@ -519,36 +648,73 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->setMouseTracking(true); userTree->viewport()->setMouseTracking(true); userTree->viewport()->installEventFilter(this); + userTree->installEventFilter(this); // keyboard handling for section dividers + + // Clicking anywhere outside the list clears its selection and closes the + // popup. The filter watches all widgets because the press can land on any + // part of the window, on another list or on the popup itself. + qApp->installEventFilter(this); // Pin on item click connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { + // Clicking a section divider toggles it + if (sectioned && item->type() == SectionItemType) { + setExpandedProgrammatically(item, !item->isExpanded()); + handleSectionExpansion(item, item->isExpanded()); + return; + } if (!SettingsCache::instance().appearance().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 + if (item->type() != QTreeWidgetItem::Type) { + return; // divider rows have no user popup + } + popupPinned = false; // reset so showPopupForUser can update + showPopupForUser(static_cast(item)); + popupPinned = true; // pin after showing }); connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, [this](const QItemSelection &sel, const QItemSelection &) { - // if (m_rebuildingTree) return; - if (sel.isEmpty() && m_popupPinned) { - m_popupPinned = false; + if (sel.isEmpty() && popupPinned) { + popupPinned = false; hidePopup(); } }); + // Keyboard selection: show the popup for the current row and hide it when + // the focus moves to a section divider or leaves the list entirely. The + // popup therefore follows arrow key navigation exactly like mouse hover. + // When it was pinned by a click it stays open and follows the selection. + connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) { + if (!isVisible() || !SettingsCache::instance().appearance().getStyleUserList()) { + return; + } + if (current && current->type() == QTreeWidgetItem::Type) { + showPopupForUser(static_cast(current)); + } else { + popupPinned = false; + hidePopup(); + } + }); + + // Section dividers can be collapsed/expanded by the user. Surface those + // changes only from real user interaction. Programmatic expansion is + // applied through setSectionExpanded() / setExpandedProgrammatically(). + connect(userTree, &QTreeWidget::itemExpanded, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, true); }); + connect(userTree, &QTreeWidget::itemCollapsed, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); }); + // Hide popup when list scrolls (reference row has moved) connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { - m_showPopupTimer->stop(); + showPopupTimer->stop(); hidePopup(true); requestAvatarsForVisibleItems(); }); // Forward join requests from popup upward - connect(m_userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); + connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, &UserListWidget::refreshVisibleUserHeader); connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, &UserListWidget::refreshVisibleUserHeader); @@ -557,6 +723,17 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, &UserListWidget::applyDisplayMode); applyDisplayMode(); + // The tree's cached palette can go stale after a runtime theme change (Qt + // freezes widget palettes when the style is switched), so rows and dividers + // derive all colors from the application palette at paint time. The theme + // change only needs a repaint to pick them up. + if (themeManager) { + connect(themeManager, &ThemeManager::themeChanged, this, [this] { + userTree->viewport()->update(); + userTree->update(); + }); + } + QVBoxLayout *vbox = new QVBoxLayout; vbox->addWidget(userTree); @@ -565,6 +742,11 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, retranslateUi(); } +UserListWidget::~UserListWidget() +{ + qApp->removeEventFilter(this); +} + void UserListWidget::bind(UserListManager *mgr) { manager = mgr; @@ -572,50 +754,70 @@ void UserListWidget::bind(UserListManager *mgr) // ── Full rebuild: disconnect / reconnect / bulk initial load ────────────── connect(manager, &UserListManager::listReset, this, &UserListWidget::rebuild); - // ── Online users list (AllUsersList / RoomList) ─────────────────────────── - if (type == AllUsersList || type == RoomList) { + if (!sectioned) { + // Online users list (AllUsersList / RoomList) + if (type == AllUsersList || type == RoomList) { + connect(manager, &UserListManager::userJoinedOnline, this, + [this](const ServerInfo_User &user) { processUserInfo(user, true); }); + connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { deleteUser(name); }); + } + + // Buddy list + if (type == BuddyList) { + connect(manager, &UserListManager::addedToBuddyList, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + processUserInfo(user, manager->getOnlineUser(name) != nullptr); + }); + connect(manager, &UserListManager::removedFromBuddyList, this, + [this](const QString &name) { deleteUser(name); }); + // Track online presence changes for buddies already in the tree + connect(manager, &UserListManager::userJoinedOnline, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + if (users.contains(name)) { + users[name]->setUserInfo(user); + setUserOnline(name, true); + } + }); + connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { + if (users.contains(name)) { + setUserOnline(name, false); + } + }); + } + + // Ignore list + if (type == IgnoreList) { + connect(manager, &UserListManager::addedToIgnoreList, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + processUserInfo(user, manager->getOnlineUser(name) != nullptr); + }); + connect(manager, &UserListManager::removedFromIgnoreList, this, + [this](const QString &name) { deleteUser(name); }); + } + } else { + // Sectioned mode: one tree, every source feeds its own section. + // Sections are pure membership views: the "Online" section holds every + // currently online user, the "Buddy"/"Ignore" sections hold those + // lists. A user can therefore appear in several sections at once (an + // online buddy gets one row in each). connect(manager, &UserListManager::userJoinedOnline, this, - [this](const ServerInfo_User &user) { processUserInfo(user, true); }); - connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { deleteUser(name); }); - } - - // ── Buddy list ──────────────────────────────────────────────────────────── - if (type == BuddyList) { - connect(manager, &UserListManager::addedToBuddyList, this, [this](const ServerInfo_User &user) { - const QString name = QString::fromStdString(user.name()); - processUserInfo(user, manager->getOnlineUser(name) != nullptr); - }); + [this](const ServerInfo_User &user) { handleOnlineChange(user); }); + connect(manager, &UserListManager::userLeftOnline, this, + [this](const QString &name) { handleOnlineChangeLeft(name); }); + connect(manager, &UserListManager::addedToBuddyList, this, + [this](const ServerInfo_User &user) { handleListAdd(Section::Buddy, user); }); connect(manager, &UserListManager::removedFromBuddyList, this, - [this](const QString &name) { deleteUser(name); }); - // Track online presence changes for buddies already in the tree - connect(manager, &UserListManager::userJoinedOnline, this, [this](const ServerInfo_User &user) { - const QString name = QString::fromStdString(user.name()); - if (users.contains(name)) { - users[name]->setUserInfo(user); - setUserOnline(name, true); - } - }); - connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { - if (users.contains(name)) { - setUserOnline(name, false); - } - }); - } - - // ── Ignore list ─────────────────────────────────────────────────────────── - if (type == IgnoreList) { - connect(manager, &UserListManager::addedToIgnoreList, this, [this](const ServerInfo_User &user) { - const QString name = QString::fromStdString(user.name()); - processUserInfo(user, manager->getOnlineUser(name) != nullptr); - }); + [this](const QString &name) { handleListRemove(Section::Buddy, name); }); + connect(manager, &UserListManager::addedToIgnoreList, this, + [this](const ServerInfo_User &user) { handleListAdd(Section::Ignore, user); }); connect(manager, &UserListManager::removedFromIgnoreList, this, - [this](const QString &name) { deleteUser(name); }); + [this](const QString &name) { handleListRemove(Section::Ignore, name); }); } // ── Popup button refresh ────────────────────────────────────────────────── // Any buddy/ignore mutation while the popup is open refreshes its buttons auto refreshIfPopupOpen = [this](const QString &name) { - if (m_userInfoPopup && m_userInfoPopup->isVisible() && m_userInfoPopup->currentUser() == name) { + if (userInfoPopup && userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == name) { refreshPopupButtons(name); } }; @@ -636,8 +838,8 @@ void UserListWidget::bind(UserListManager *mgr) void UserListWidget::refreshVisibleUserHeader(const QString &name) { userTree->viewport()->update(); - if (m_userInfoPopup->isVisible() && m_userInfoPopup->currentUser() == name) { - m_userInfoPopup->refreshHeader(); + if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == name) { + userInfoPopup->refreshHeader(); } } @@ -653,15 +855,15 @@ void UserListWidget::refreshPopupButtons(const QString &userName) const bool isBuddy = proxy->isUserBuddy(userName); const bool isIgn = proxy->isUserIgnored(userName); - m_userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn); - positionPopup(userName); // height may have changed — reposition + userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn); + positionPopup(item); // height may have changed, reposition } void UserListWidget::hideEvent(QHideEvent *e) { QGroupBox::hideEvent(e); - m_showPopupTimer->stop(); - m_hidePopupTimer->stop(); + showPopupTimer->stop(); + hidePopupTimer->stop(); hidePopup(true); } @@ -692,72 +894,106 @@ void UserListWidget::applyDisplayMode() void UserListWidget::connectPopupSignals() { - connect(m_userInfoPopup, &UserInfoPopup::closeRequested, this, [this] { - m_popupPinned = false; + connect(userInfoPopup, &UserInfoPopup::closeRequested, this, [this] { + 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(); + connect(userInfoPopup, &UserInfoPopup::mouseEnteredPopup, hidePopupTimer, &QTimer::stop); + connect(userInfoPopup, &UserInfoPopup::mouseLeftPopup, this, [this] { + if (!popupPinned) { + 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, + connect(userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat); + connect(userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails); + connect(userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames); + connect(userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy); + connect(userInfoPopup, &UserInfoPopup::removeBuddyRequested, userContextMenu, &UserContextMenu::execRemoveFromBuddy); - connect(m_userInfoPopup, &UserInfoPopup::addIgnoreRequested, userContextMenu, &UserContextMenu::execAddToIgnore); - connect(m_userInfoPopup, &UserInfoPopup::removeIgnoreRequested, userContextMenu, + connect(userInfoPopup, &UserInfoPopup::addIgnoreRequested, userContextMenu, &UserContextMenu::execAddToIgnore); + connect(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, + connect(userInfoPopup, &UserInfoPopup::banRequested, userContextMenu, &UserContextMenu::execBan); + connect(userInfoPopup, &UserInfoPopup::warnRequested, userContextMenu, &UserContextMenu::execWarn); + connect(userInfoPopup, &UserInfoPopup::banHistoryRequested, userContextMenu, &UserContextMenu::execBanHistory); + connect(userInfoPopup, &UserInfoPopup::warnHistoryRequested, userContextMenu, &UserContextMenu::execWarnHistory); + connect(userInfoPopup, &UserInfoPopup::adminNotesRequested, userContextMenu, &UserContextMenu::execAdminNotes); + connect(userInfoPopup, &UserInfoPopup::promoteToModRequested, this, [this](const QString &n) { userContextMenu->execAdjustMod(n, true); }); - connect(m_userInfoPopup, &UserInfoPopup::demoteFromModRequested, this, + connect(userInfoPopup, &UserInfoPopup::demoteFromModRequested, this, [this](const QString &n) { userContextMenu->execAdjustMod(n, false); }); - connect(m_userInfoPopup, &UserInfoPopup::promoteToJudgeRequested, this, + connect(userInfoPopup, &UserInfoPopup::promoteToJudgeRequested, this, [this](const QString &n) { userContextMenu->execAdjustJudge(n, true); }); - connect(m_userInfoPopup, &UserInfoPopup::demoteFromJudgeRequested, this, + connect(userInfoPopup, &UserInfoPopup::demoteFromJudgeRequested, this, [this](const QString &n) { userContextMenu->execAdjustJudge(n, false); }); } bool UserListWidget::eventFilter(QObject *obj, QEvent *event) { + // A press outside the tree, the popup and any open menu deselects the + // list and closes the popup. The filter is installed application-wide, so + // the target can be any widget in the window or another list. + if (event->type() == QEvent::MouseButtonPress) { + auto *pressTarget = qobject_cast(obj); + if (pressTarget && !isPressInsideListUi(pressTarget)) { + clearSelectionAndClosePopup(); + } + } + + // Keyboard navigation of the section dividers. + // 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 + // the tree convention (Left collapses, Right expands). + if (obj == userTree && event->type() == QEvent::KeyPress) { + auto *keyEvent = static_cast(event); + QTreeWidgetItem *current = userTree->currentItem(); + if (sectioned && current && current->type() == SectionItemType) { + const bool toggle = keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter || + keyEvent->key() == Qt::Key_Space; + const bool collapse = keyEvent->key() == Qt::Key_Left && current->isExpanded(); + const bool expand = keyEvent->key() == Qt::Key_Right && !current->isExpanded(); + if (toggle || collapse || expand) { + const bool expanded = toggle ? !current->isExpanded() : expand; + setExpandedProgrammatically(current, expanded); + handleSectionExpansion(current, expanded); + return true; + } + } + } + if (obj == userTree->viewport()) { if (event->type() == QEvent::MouseMove) { if (!SettingsCache::instance().appearance().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{}; + QTreeWidgetItem *hoveredItem = userTree->itemAt(me->pos()); + QString hovName; + if (hoveredItem && hoveredItem->type() == QTreeWidgetItem::Type) { + hovName = QString::fromStdString(static_cast(hoveredItem)->getUserInfo().name()); + } - if (hovName != m_hoveredUser) { - m_hoveredUser = hovName; + if (hovName != hoveredUser) { + hoveredUser = hovName; if (!hovName.isEmpty()) { - m_hidePopupTimer->stop(); - if (!m_popupPinned) { - m_showPopupTimer->start(); + hidePopupTimer->stop(); + if (!popupPinned) { + showPopupTimer->start(); } } else { - m_showPopupTimer->stop(); - if (!m_popupPinned) { - m_hidePopupTimer->start(); + showPopupTimer->stop(); + if (!popupPinned) { + hidePopupTimer->start(); } } } } else if (event->type() == QEvent::Leave) { - m_hoveredUser.clear(); - m_showPopupTimer->stop(); - if (!m_popupPinned) { - m_hidePopupTimer->start(); + hoveredUser.clear(); + showPopupTimer->stop(); + if (!popupPinned) { + hidePopupTimer->start(); } } } @@ -765,13 +1001,13 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event) return QGroupBox::eventFilter(obj, event); } -void UserListWidget::showPopupForUser(const QString &userName) +void UserListWidget::showPopupForUser(UserListTWI *item) { - UserListTWI *item = users.value(userName); if (!item) { return; } + const QString userName = QString::fromStdString(item->getUserInfo().name()); avatarProvider->requestAvatar(userName); // ensure the hovered user's avatar is fetched promptly const ServerInfo_User &info = item->getUserInfo(); @@ -779,29 +1015,52 @@ void UserListWidget::showPopupForUser(const QString &userName) const bool isBuddy = userContextMenu->getUserListProxy()->isUserBuddy(userName); const bool isIgn = userContextMenu->getUserListProxy()->isUserIgnored(userName); - m_userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn); + // The popup is already showing this user (e.g. arrow key navigation between + // the online/buddy rows of the same user): just reposition it. + if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == userName) { + positionPopup(item); + return; + } - // Realize the native window at opacity 0 before positioning so that: - // 1) move() applies to an existing native handle (not overridden by Qt's - // default centering logic on first show) - // 2) adjustSize() inside positionPopup() can measure the final laid-out - // geometry correctly - m_userInfoPopup->setWindowOpacity(0.0); - m_userInfoPopup->show(); - m_userInfoPopup->raise(); + // Cancel any pending show/hide so a hover timer that armed before a row + // selected via the keyboard cannot override it, and a pending hide cannot + // kill the popup right after it appears. + showPopupTimer->stop(); + hidePopupTimer->stop(); - positionPopup(userName); // geometry is now accurate; move() sticks + userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn); - auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); + const bool wasVisible = userInfoPopup->isVisible(); + if (!wasVisible) { + // Realize the native window at opacity 0 before positioning so that: + // 1) move() applies to an existing native handle (not overridden by + // Qt's default centering logic on first show) + // 2) adjustSize() inside positionPopup() can measure the final + // laid out geometry correctly + userInfoPopup->setWindowOpacity(0.0); + } + userInfoPopup->show(); + userInfoPopup->raise(); + + positionPopup(item); // geometry is accurate after show, so move() is not overridden + + if (wasVisible) { + // Content swap while already open (hover or arrow key navigation): + // keep the popup opaque instead of flashing through a fade on every + // step. + userInfoPopup->setWindowOpacity(1.0); + return; + } + + auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); fade->setDuration(120); fade->setStartValue(0.0); fade->setEndValue(1.0); fade->start(QAbstractAnimation::DeleteWhenStopped); } -void UserListWidget::positionPopup(const QString &userName) +void UserListWidget::positionPopup(UserListTWI *item) { - UserListTWI *item = users.value(userName); if (!item) { return; } @@ -812,9 +1071,9 @@ void UserListWidget::positionPopup(const QString &userName) const QPoint vpTL = vp->mapToGlobal(vp->rect().topLeft()); const QPoint vpTR = vp->mapToGlobal(vp->rect().topRight()); - m_userInfoPopup->adjustSize(); - const int popW = m_userInfoPopup->width(); - const int popH = m_userInfoPopup->height(); + userInfoPopup->adjustSize(); + const int popW = userInfoPopup->width(); + const int popH = userInfoPopup->height(); const int margin = 12; QScreen *activeScreen = QGuiApplication::screenAt(itemTL); @@ -851,31 +1110,50 @@ void UserListWidget::positionPopup(const QString &userName) } y = qBound(screen.top() + margin, y, screen.bottom() - popH - margin); - m_userInfoPopup->move(x, y); + userInfoPopup->move(x, y); } void UserListWidget::hidePopup(bool immediate) { - m_showPopupTimer->stop(); - m_hidePopupTimer->stop(); - if (!m_userInfoPopup->isVisible()) { + showPopupTimer->stop(); + hidePopupTimer->stop(); + if (!userInfoPopup->isVisible()) { return; } if (immediate) { - m_userInfoPopup->hide(); + userInfoPopup->hide(); return; } // Fade out - auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); + auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); fade->setDuration(100); - fade->setStartValue(m_userInfoPopup->windowOpacity()); + fade->setStartValue(userInfoPopup->windowOpacity()); fade->setEndValue(0.0); - connect(fade, &QPropertyAnimation::finished, m_userInfoPopup, &QWidget::hide); + connect(fade, &QPropertyAnimation::finished, userInfoPopup, &QWidget::hide); fade->start(QAbstractAnimation::DeleteWhenStopped); } +bool UserListWidget::isPressInsideListUi(const QWidget *widget) const +{ + const QWidget *w = widget; + while (w) { + if (w == userTree || w == userInfoPopup || qobject_cast(w)) { + return true; + } + w = w->parentWidget(); + } + return false; +} + +void UserListWidget::clearSelectionAndClosePopup() +{ + popupPinned = false; + hidePopup(true); + userTree->clearSelection(); +} + void UserListWidget::retranslateUi() { userContextMenu->retranslateUi(); @@ -898,13 +1176,14 @@ void UserListWidget::retranslateUi() void UserListWidget::beginBulkLoad() { - m_bulkLoading = true; + bulkLoading = true; } void UserListWidget::endBulkLoad() { - m_bulkLoading = false; + bulkLoading = false; sortItems(); + updateCount(); // divider counts were deferred during the bulk build requestAvatarsForVisibleItems(); userTree->viewport()->update(); } @@ -920,6 +1199,23 @@ bool UserListWidget::isItemNearViewport(const UserListTWI *item) const void UserListWidget::requestAvatarsForVisibleItems() { + if (sectioned) { + // Top level items are dividers, user rows hang below them. + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + continue; + } + for (int i = 0; i < divider->childCount(); ++i) { + auto *twi = static_cast(divider->child(i)); + if (isItemNearViewport(twi)) { + avatarProvider->requestAvatar(QString::fromStdString(twi->getUserInfo().name())); + } + } + } + return; + } + for (int i = 0; i < userTree->topLevelItemCount(); ++i) { auto *twi = static_cast(userTree->topLevelItem(i)); if (isItemNearViewport(twi)) { @@ -932,13 +1228,40 @@ void UserListWidget::rebuild() { userTree->clear(); users.clear(); + sectionUsers.clear(); cardArtParamsMap.clear(); onlineCount = 0; + if (sectioned) { + createSectionItems(); + } + if (!manager) { return; } + if (sectioned) { + // Every source feeds its own section. Users that belong to several + // sources (an online buddy) get one row per section because + // ensureSectionMembership() creates the row when it is missing. + beginBulkLoad(); + const auto &onlineUsers = manager->getAllUsersList(); + for (auto it = onlineUsers.cbegin(); it != onlineUsers.cend(); ++it) { + processUserInfo(Section::Online, it.value(), true); + } + const auto &buddyUsers = manager->getBuddyList(); + for (auto it = buddyUsers.cbegin(); it != buddyUsers.cend(); ++it) { + processUserInfo(Section::Buddy, it.value(), manager->getOnlineUser(it.key()) != nullptr); + } + const auto &ignoreUsers = manager->getIgnoreList(); + for (auto it = ignoreUsers.cbegin(); it != ignoreUsers.cend(); ++it) { + processUserInfo(Section::Ignore, it.value(), manager->getOnlineUser(it.key()) != nullptr); + } + endBulkLoad(); + applyFilter(); + return; + } + const QMap *source = nullptr; switch (type) { @@ -959,14 +1282,13 @@ void UserListWidget::rebuild() processUserInfo(it.value(), manager->getOnlineUser(it.key()) != nullptr); } endBulkLoad(); + applyFilter(); } -void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) +void UserListWidget::updateCardArtParams(const ServerInfo_User &user, const QString &userName) { - const QString userName = QString::fromStdString(user.name()); - // Always update params from the latest ServerInfo_User, whether the - // item is new or existing, so a live server-push refreshes the rendering. + // item is new or existing, so a live server push refreshes the rendering. if (user.has_card_art_params()) { const auto &cap = user.card_art_params(); CardArtParams params; @@ -981,6 +1303,13 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) } else { cardArtParamsMap.remove(userName); // clear stale params on removal } +} + +void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) +{ + const QString userName = QString::fromStdString(user.name()); + + updateCardArtParams(user, userName); UserListTWI *item = users.value(userName); if (item) { @@ -993,41 +1322,92 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) ++onlineCount; } updateCount(); - if (!m_bulkLoading && isItemNearViewport(item)) { + if (!bulkLoading && isItemNearViewport(item)) { avatarProvider->requestAvatar(userName); } } item->setOnline(online); - if (!m_bulkLoading) { + if (!bulkLoading) { sortItems(); + applyFilter(); + userTree->viewport()->update(); + } +} + +void UserListWidget::processUserInfo(Section section, const ServerInfo_User &user, bool online) +{ + ensureSectionMembership(section, user, online); + if (!bulkLoading) { + sortItems(); + applyFilter(); userTree->viewport()->update(); } } bool UserListWidget::deleteUser(const QString &userName) { + if (sectioned) { + // The user may own several rows (one per section). Drop them all. + bool removed = false; + const QList
sections = sectionUsers.keys(); // snapshot: maps mutate + for (const Section section : sections) { + removed = dropSectionMembership(section, userName) || removed; + } + if (removed && !bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } + return removed; + } + UserListTWI *twi = users.value(userName); if (!twi) { return false; } users.remove(userName); - userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi)); - if (twi->data(0, Qt::UserRole + 1).toBool()) { + if (twi->parent()) { + twi->parent()->removeChild(twi); // sectioned mode: rows hang off a divider + } else { + userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi)); + } + if (twi->data(0, UserListRoles::Online).toBool()) { --onlineCount; } delete twi; updateCount(); + applyFilter(); return true; } void UserListWidget::setUserOnline(const QString &userName, bool online) { + if (sectioned) { + // The rows in the "Online" section are created/removed by the presence + // handlers. This only keeps the presence flag of the surviving rows + // (e.g. a buddy row after the user went offline) in sync. + for (auto it = sectionUsers.cbegin(); it != sectionUsers.cend(); ++it) { + UserListTWI *item = it.value().value(userName); + if (item) { + item->setOnline(online); + } + } + return; + } + UserListTWI *twi = users.value(userName); if (!twi) { return; } + // No state change: nothing to resort. This also keeps the presence + // broadcasts cheap (userJoinedOnline fires once per online user) when the + // row already carries the right flag. + if (twi->data(0, UserListRoles::Online).toBool() == online) { + return; + } + twi->setOnline(online); if (online) { ++onlineCount; @@ -1035,26 +1415,123 @@ void UserListWidget::setUserOnline(const QString &userName, bool online) --onlineCount; } updateCount(); + + // Online users sort above offline users (UserListTWI::operator<), so a + // flag change moves the row. Resort to place the user by the new state. + if (!bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } } void UserListWidget::updateCount() { - QString str = titleStr; - if ((type == BuddyList) || (type == IgnoreList)) { - str = str.arg(onlineCount); + if (sectioned) { + // The dividers carry the section titles + setTitle(QString()); + for (const Section section : sectionIds) { + updateSectionDivider(section); + } + return; } - setTitle(str.arg(userTree->topLevelItemCount())); + + if (showTitle) { + QString str = titleStr; + if ((type == BuddyList) || (type == IgnoreList)) { + str = str.arg(onlineCount); + } + setTitle(str.arg(userTree->topLevelItemCount())); + } else { + setTitle(QString()); + } +} + +void UserListWidget::setShowTitle(bool showTitle) +{ + this->showTitle = showTitle; + updateCount(); +} + +void UserListWidget::setFilterText(const QString &text) +{ + if (filterText == text) { + return; + } + filterText = text; + applyFilter(); +} + +void UserListWidget::applyFilter() +{ + if (sectioned) { + const bool searching = !filterText.isEmpty(); + const QString lower = filterText.toLower(); + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + continue; + } + int visible = 0; + for (int i = 0; i < divider->childCount(); ++i) { + auto *child = static_cast(divider->child(i)); + const bool match = + !searching || QString::fromStdString(child->getUserInfo().name()).toLower().contains(lower); + child->setHidden(!match); + if (match) { + ++visible; + } + } + if (searching) { + // During a search the sections with matches stay open and empty + // sections disappear entirely. The persisted expansion state is + // untouched and restored when the search is cleared. + divider->setHidden(visible == 0); + setExpandedProgrammatically(divider, visible > 0); + } else { + divider->setHidden(false); + setExpandedProgrammatically(divider, expandedSections.contains(section)); + } + updateSectionDivider(section); + } + requestAvatarsForVisibleItems(); + userTree->viewport()->update(); + return; + } + + if (filterText.isEmpty()) { + for (auto it = users.cbegin(); it != users.cend(); ++it) { + it.value()->setHidden(false); + } + } else { + const QString lower = filterText.toLower(); + for (auto it = users.cbegin(); it != users.cend(); ++it) { + const bool match = QString::fromStdString(it.value()->getUserInfo().name()).toLower().contains(lower); + it.value()->setHidden(!match); + } + } + + requestAvatarsForVisibleItems(); + userTree->viewport()->update(); } void UserListWidget::userClicked(QTreeWidgetItem *item, int /*column*/) { + if (item->type() != QTreeWidgetItem::Type) { + return; // divider rows open no chat + } emit openMessageDialog(item->data(2, Qt::UserRole).toString(), true); } void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index) { - const ServerInfo_User &userInfo = static_cast(userTree->topLevelItem(index.row()))->getUserInfo(); - bool online = index.sibling(index.row(), 0).data(Qt::UserRole + 1).toBool(); + QTreeWidgetItem *item = userTree->itemFromIndex(index); + if (!item || item->type() != QTreeWidgetItem::Type) { + return; // divider rows have no user menu + } + const auto *userItem = static_cast(item); + const ServerInfo_User &userInfo = userItem->getUserInfo(); + const bool online = userItem->data(0, UserListRoles::Online).toBool(); userContextMenu->showContextMenu(pos, QString::fromStdString(userInfo.name()), UserLevelFlags(userInfo.user_level()), online); @@ -1062,5 +1539,289 @@ void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index void UserListWidget::sortItems() { + if (sectioned) { + // Sorting must stay inside each section so the dividers keep their + // places as top level items. + for (auto it = sectionItems.cbegin(); it != sectionItems.cend(); ++it) { + it.value()->sortChildren(0, Qt::AscendingOrder); + } + return; + } userTree->sortItems(0, Qt::AscendingOrder); } + +// Sectioned mode + +void UserListWidget::setSectioned(const QList
&ids) +{ + if (sectioned || ids.isEmpty()) { + return; + } + + sectioned = true; + sectionIds = ids; + expandedSections.clear(); + for (const Section section : sectionIds) { + expandedSections.insert(section); // everything starts expanded + } + + // The single tree owns scrolling and the dividers carry the section titles, + // so the group box chrome and tree decorations collapse into a flat list. + setFlat(true); + setShowTitle(false); + userTree->setFrameStyle(QFrame::NoFrame); + // No tree branches: the dividers draw their own arrow glyph, so the rows can + // sit flush with the left border. + userTree->setRootIsDecorated(false); + userTree->setIndentation(0); + userTree->setAlternatingRowColors(false); + if (auto *listLayout = layout()) { + listLayout->setContentsMargins(0, 0, 0, 0); + } + + createSectionItems(); + updateCount(); +} + +void UserListWidget::createSectionItems() +{ + sectionItems.clear(); + QSignalBlocker blocker(userTree); // no expansion signals while building + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = createSectionItem(section); + sectionItems.insert(section, divider); + divider->setExpanded(expandedSections.contains(section)); + } +} + +QTreeWidgetItem *UserListWidget::createSectionItem(Section section) +{ + Q_UNUSED(section); + auto *divider = new QTreeWidgetItem(SectionItemType); + // Selectable so keyboard navigation (Up/Down) can land on the dividers. + // They act as collapsible section headers once they have focus. + divider->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); + + QFont font = userTree->font(); + font.setBold(true); + divider->setFont(0, font); + // A little taller than a plain text row so the header reads as a section + // separator without matching the full user row height. + divider->setSizeHint(0, QSize(0, QFontMetrics(font).height() + 16)); + + userTree->addTopLevelItem(divider); + // QTreeWidgetItem::setFirstColumnSpanned() does nothing while the item is + // detached from the tree (Qt returns early when treeModel() is null), so it + // must be called after addTopLevelItem(). Without the span the divider text + // is confined to column 0 and gets elided in unstyled mode. + divider->setFirstColumnSpanned(true); + return divider; +} + +QString UserListWidget::sectionTitle(Section section) const +{ + switch (section) { + case Section::Buddy: + return tr("Buddies"); + case Section::Online: + return tr("Online"); + case Section::Ignore: + return tr("Ignored"); + } + return {}; +} + +void UserListWidget::updateSectionDivider(Section section) +{ + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + return; + } + int visible = 0; + for (int i = 0; i < divider->childCount(); ++i) { + if (!divider->child(i)->isHidden()) { + ++visible; + } + } + // The tree draws no branches (rows are flush), so the divider carries its + // own collapse arrow glyph. + const QString arrow = divider->isExpanded() ? QStringLiteral("\u25BE") : QStringLiteral("\u25B8"); + divider->setText(0, tr("%1 %2 (%3)").arg(arrow, sectionTitle(section)).arg(visible)); +} + +void UserListWidget::handleSectionExpansion(QTreeWidgetItem *item, bool expanded) +{ + if (!sectioned || item->type() != SectionItemType) { + return; + } + // Reverse lookup. Only three dividers exist, so a linear scan over the + // section map is cheaper than caching the section on each divider. + auto dividerIt = sectionItems.constBegin(); + while (dividerIt != sectionItems.constEnd() && dividerIt.value() != item) { + ++dividerIt; + } + if (dividerIt == sectionItems.constEnd()) { + return; + } + const Section section = dividerIt.key(); + if (expanded) { + expandedSections.insert(section); + } else { + expandedSections.remove(section); + } + updateSectionDivider(section); // the arrow glyph follows the state + emit sectionExpanded(section, expanded); +} + +void UserListWidget::setExpandedProgrammatically(QTreeWidgetItem *item, bool expanded) +{ + QSignalBlocker blocker(userTree); + item->setExpanded(expanded); +} + +void UserListWidget::setSectionExpanded(Section section, bool expanded) +{ + if (!sectioned) { + return; + } + if (expanded) { + expandedSections.insert(section); + } else { + expandedSections.remove(section); + } + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + return; + } + QSignalBlocker blocker(userTree); + divider->setExpanded(expanded); + updateSectionDivider(section); // the arrow glyph follows the state + userTree->viewport()->update(); +} + +void UserListWidget::handleOnlineChange(const ServerInfo_User &user) +{ + // A user came online: they get a row in the "Online" section, plus (if + // applicable) a row in the buddy/ignore sections, which flip to online. + const QString name = QString::fromStdString(user.name()); + ensureSectionMembership(Section::Online, user, true); + if (manager->isUserBuddy(name)) { + ensureSectionMembership(Section::Buddy, user, true); + } + if (manager->isUserIgnored(name)) { + ensureSectionMembership(Section::Ignore, user, true); + } + finishSectionedMutation(); +} + +void UserListWidget::handleOnlineChangeLeft(const QString &userName) +{ + // The user is no longer online: their "Online" row disappears. Buddies and + // ignored users keep their own section's row, marked offline. A plain user + // has no rows left. + const bool dropped = dropSectionMembership(Section::Online, userName); + const bool kept = manager->isUserBuddy(userName) || manager->isUserIgnored(userName); + if (kept) { + setUserOnline(userName, false); + } + if (dropped || kept) { + finishSectionedMutation(); + } +} + +void UserListWidget::handleListAdd(Section section, const ServerInfo_User &user) +{ + const QString name = QString::fromStdString(user.name()); + const bool online = manager->getOnlineUser(name) != nullptr; + ensureSectionMembership(section, user, online); + if (online) { + // The user belongs to the "Online" section as well. Make sure the row + // exists even if the join event raced ahead of the list mutation. + ensureSectionMembership(Section::Online, user, true); + } + finishSectionedMutation(); +} + +void UserListWidget::handleListRemove(Section section, const QString &userName) +{ + // Only the row of the removed section disappears: an online user keeps + // their "Online" row, and other list memberships keep theirs. + if (dropSectionMembership(section, userName)) { + finishSectionedMutation(); + } +} + +UserListTWI *UserListWidget::ensureSectionMembership(Section section, const ServerInfo_User &user, bool online) +{ + const QString userName = QString::fromStdString(user.name()); + + updateCardArtParams(user, userName); + + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + return nullptr; + } + + QMap §ionMap = sectionUsers[section]; + UserListTWI *item = sectionMap.value(userName); + if (!item) { + item = new UserListTWI(user); + sectionMap.insert(userName, item); + divider->addChild(item); + if (!users.contains(userName)) { + users.insert(userName, item); // primary row for lookups by name + } + // The divider counts are refreshed once in endBulkLoad(). Calling + // updateCount() per row during a large rebuild would be quadratic. + if (!bulkLoading) { + updateCount(); // a new row changes the divider's count + } + if (!bulkLoading && isItemNearViewport(item)) { + avatarProvider->requestAvatar(userName); + } + } else { + item->setUserInfo(user); + } + item->setOnline(online); + return item; +} + +bool UserListWidget::dropSectionMembership(Section section, const QString &userName) +{ + QMap §ionMap = sectionUsers[section]; + UserListTWI *item = sectionMap.take(userName); + if (!item) { + return false; + } + + if (item->parent()) { + item->parent()->removeChild(item); + } else { + userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(item)); + } + if (users.value(userName) == item) { + // Repoint the primary row at another surviving row, if any. + UserListTWI *replacement = nullptr; + for (auto it = sectionUsers.cbegin(); it != sectionUsers.cend() && !replacement; ++it) { + replacement = it.value().value(userName); + } + if (replacement) { + users.insert(userName, replacement); + } else { + users.remove(userName); + } + } + delete item; + updateCount(); + return true; +} + +void UserListWidget::finishSectionedMutation() +{ + if (bulkLoading) { + return; + } + sortItems(); + applyFilter(); + userTree->viewport()->update(); +} 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 c98ebebdf..298a5f8d8 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -103,12 +104,13 @@ public: class UserListItemDelegate : public QStyledItemDelegate { + QTreeWidget *tree; const QMap *avatarCache; const QMap *cardArtCache; const QMap *cardArtParamsMap; public: - explicit UserListItemDelegate(QObject *const parent, + explicit UserListItemDelegate(QTreeWidget *tree, const QMap *avatarCache, const QMap *cardArtCache, const QMap *cardArtParamsMap); @@ -147,6 +149,12 @@ public: BuddyList, IgnoreList }; + enum class Section + { + Buddy, + Online, + Ignore + }; private: UserListManager *manager = nullptr; @@ -154,30 +162,69 @@ private: 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; - bool m_bulkLoading = false; + UserInfoPopup *userInfoPopup = nullptr; + QTimer *showPopupTimer = nullptr; + QTimer *hidePopupTimer = nullptr; + QString hoveredUser; + bool popupPinned = false; + bool bulkLoading = false; - void showPopupForUser(const QString &userName); + /** + * Popup functions are anchored on the row, not the user name. In sectioned + * mode a user can own several rows (online + buddy), and the popup must + * follow the hovered/selected row rather than a lookup by name. + */ + void showPopupForUser(UserListTWI *item); void hidePopup(bool immediate = false); - void positionPopup(const QString &userName); + void positionPopup(UserListTWI *item); void connectPopupSignals(); + /** True when @p widget is the tree, the popup or an open menu. */ + bool isPressInsideListUi(const QWidget *widget) const; + void clearSelectionAndClosePopup(); bool isItemNearViewport(const UserListTWI *item) const; void requestAvatarsForVisibleItems(); + // Sectioned mode (single tree with inline dividers) + bool sectioned = false; + QList
sectionIds; + QMap sectionItems; + // One row per (section, user): a user that is online AND a buddy appears in + // both the "Online" and the "Buddies" sections, so the same user can own + // several rows, each hanging off its section's divider. + QMap> sectionUsers; + QSet
expandedSections; + void createSectionItems(); + QTreeWidgetItem *createSectionItem(Section section); + [[nodiscard]] QString sectionTitle(Section section) const; + void updateSectionDivider(Section section); + void handleSectionExpansion(QTreeWidgetItem *item, bool expanded); + void setExpandedProgrammatically(QTreeWidgetItem *item, bool expanded); + void handleOnlineChange(const ServerInfo_User &user); + void handleOnlineChangeLeft(const QString &userName); + void handleListAdd(Section section, const ServerInfo_User &user); + void handleListRemove(Section section, const QString &userName); + /** Creates or updates the row for @p user in @p section. */ + UserListTWI *ensureSectionMembership(Section section, const ServerInfo_User &user, bool online); + /** Removes and deletes the row for @p userName in @p section. */ + bool dropSectionMembership(Section section, const QString &userName); + /** Sorts, refilters and repaints after a sectioned mode mutation. */ + void finishSectionedMutation(); + void updateCardArtParams(const ServerInfo_User &user, const QString &userName); + void processUserInfo(Section section, const ServerInfo_User &user, bool online); + QMap users; TabSupervisor *tabSupervisor; AbstractClient *client; UserListType type; - QTreeWidget *userTree; + QTreeWidget *userTree = nullptr; UserListItemDelegate *itemDelegate; UserContextMenu *userContextMenu; int onlineCount; QString titleStr; + QString filterText; + bool showTitle = true; void updateCount(); + void applyFilter(); void refreshPopupButtons(const QString &userName); private slots: void userClicked(QTreeWidgetItem *item, int column); @@ -189,12 +236,14 @@ signals: void addIgnore(const QString &userName); void removeIgnore(const QString &userName); void joinGameRequested(int gameId, int roomId, bool asSpectator); + void sectionExpanded(Section section, bool expanded); public: UserListWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, UserListType _type, QWidget *parent = nullptr); + ~UserListWidget() override; void bind(UserListManager *mgr); void applyDisplayMode(); void beginBulkLoad(); @@ -205,6 +254,14 @@ public: void processUserInfo(const ServerInfo_User &user, bool online); bool deleteUser(const QString &userName); void setUserOnline(const QString &userName, bool online); + void setFilterText(const QString &text); + void setShowTitle(bool showTitle); + void setSectioned(const QList
&ids); + void setSectionExpanded(Section section, bool expanded); + [[nodiscard]] const QList
&getSectionIds() const + { + return sectionIds; + } [[nodiscard]] const QMap &getUsers() const { return users; diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 705266b1d..9b09ba7bb 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -6,6 +6,7 @@ #include "../interface/widgets/server/chat_view/chat_view.h" #include "../interface/widgets/server/game_selector.h" #include "../interface/widgets/server/user/user_list_manager.h" +#include "../interface/widgets/server/user/user_list_panel_widget.h" #include "../interface/widgets/server/user/user_list_widget.h" #include "../main.h" #include "../utility/completer_utils.h" @@ -60,23 +61,10 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, tempMap.insert(info.room_id(), gameTypes); gameSelector = new GameSelector(client, tabSupervisor, this, QMap(), tempMap, true, true); - auto *tabs = new QTabWidget(this); - - friendsList = new UserListWidget(tabSupervisor, client, UserListWidget::BuddyList); - friendsList->bind(tabSupervisor->getUserListManager()); - userList = new UserListWidget(tabSupervisor, client, UserListWidget::RoomList); - userList->bind(tabSupervisor->getUserListManager()); - ignoreList = new UserListWidget(tabSupervisor, client, UserListWidget::IgnoreList); - ignoreList->bind(tabSupervisor->getUserListManager()); - - connect(friendsList, SIGNAL(openMessageDialog(const QString &, bool)), this, - SIGNAL(openMessageDialog(const QString &, bool))); - connect(userList, SIGNAL(openMessageDialog(const QString &, bool)), this, - SIGNAL(openMessageDialog(const QString &, bool))); - - tabs->addTab(friendsList, tr("Friends")); - tabs->addTab(userList, tr("Online")); - tabs->addTab(ignoreList, tr("Ignored")); + userListPanel = new UserListPanelWidget(tabSupervisor, client, this); + userListPanel->bind(tabSupervisor->getUserListManager()); + userList = userListPanel->getUserList(); + connect(userListPanel, &UserListPanelWidget::openMessageDialog, this, &TabRoom::openMessageDialog); chatView = new ChatView(tabSupervisor, nullptr, true, this); connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); @@ -126,7 +114,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, auto *hbox = new QHBoxLayout; hbox->addWidget(splitter, 3); - hbox->addWidget(tabs, 1); + hbox->addWidget(userListPanel, 1); aLeaveRoom = new QAction(this); connect(aLeaveRoom, &QAction::triggered, this, &TabRoom::closeRequest); @@ -181,7 +169,7 @@ void TabRoom::retranslateUi() { gameSelector->retranslateUi(); chatView->retranslateUi(); - userList->retranslateUi(); + userListPanel->retranslateUi(); sayLabel->setText(tr("&Say:")); chatGroupBox->setTitle(tr("Chat")); roomMenu->setTitle(tr("&Room")); diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.h b/cockatrice/src/interface/widgets/tabs/tab_room.h index cdfd35d88..7d01d5cf6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.h +++ b/cockatrice/src/interface/widgets/tabs/tab_room.h @@ -27,6 +27,7 @@ class Message; } // namespace google class AbstractClient; class UserListWidget; +class UserListPanelWidget; class QLabel; class ChatView; class QPushButton; @@ -57,9 +58,8 @@ private: QMap gameTypes; GameSelector *gameSelector; - UserListWidget *friendsList; + UserListPanelWidget *userListPanel; UserListWidget *userList; - UserListWidget *ignoreList; const UserListProxy *userListProxy; ChatView *chatView; QLabel *sayLabel; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h index 1f75d3d33..b77c98357 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h @@ -2,6 +2,7 @@ #define COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H #include +#include class IInterfaceSettingsProvider { @@ -41,6 +42,7 @@ public: [[nodiscard]] virtual bool getShowGameSelectorFilterToolbar() const = 0; [[nodiscard]] virtual bool getLifeCounterAnimationsEnabled() const = 0; [[nodiscard]] virtual bool getBattlefieldFlashEnabled() const = 0; + [[nodiscard]] virtual QStringList getUserListExpandedSections() const = 0; }; #endif // COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp index 4dfc26417..2f0718533 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp @@ -170,6 +170,12 @@ bool InterfaceSettings::getBattlefieldFlashEnabled() const return getValue("battlefieldFlashEnabled", QString(), QString(), true).toBool(); } +QStringList InterfaceSettings::getUserListExpandedSections() const +{ + return getValue("userListExpandedSections", QString(), QString(), QStringList({"buddy", "online", "ignore"})) + .toStringList(); +} + void InterfaceSettings::setUseTearOffMenus(bool _useTearOffMenus) { setValue(_useTearOffMenus, "useTearOffMenus"); @@ -348,3 +354,8 @@ void InterfaceSettings::setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled setValue(_battlefieldFlashEnabled, "battlefieldFlashEnabled"); emit battlefieldFlashEnabledChanged(_battlefieldFlashEnabled); } + +void InterfaceSettings::setUserListExpandedSections(const QStringList §ions) +{ + setValue(sections, "userListExpandedSections"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.h b/libcockatrice_settings/libcockatrice/settings/interface_settings.h index df254eb09..981d28679 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.h @@ -44,6 +44,7 @@ public: [[nodiscard]] bool getShowGameSelectorFilterToolbar() const override; [[nodiscard]] bool getLifeCounterAnimationsEnabled() const override; [[nodiscard]] bool getBattlefieldFlashEnabled() const override; + [[nodiscard]] QStringList getUserListExpandedSections() const override; void setUseTearOffMenus(bool _useTearOffMenus); void setCardViewInitialRowsMax(int _cardViewInitialRowsMax); @@ -78,6 +79,7 @@ public: void setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar); void setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled); void setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled); + void setUserListExpandedSections(const QStringList §ions); signals: void useTearOffMenusChanged(bool state);