From d99798111eb92cb380df5b81ed4d643cb6eb8aea Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:53:13 +0200 Subject: [PATCH] [UserList] Unify friends/online/ignored list with section dividers and add search bar. (#7119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [UserList] Unify friends/online/ignored list with section dividers and add search bar. Took 31 minutes Took 7 seconds * [UserList] Add a light mode theme Took 12 minutes Took 11 seconds Took 5 minutes Took 2 minutes * [UserList] Re-sort when a user's online state changes setUserOnline() flipped the online flag but never re-sorted, so a buddy who went offline kept the position they had while online and stayed at the top of the list. Re-sort (and re-apply the filter) whenever the flag actually changes, mirroring processUserInfo(). Took 10 minutes * [UserList] Show users in every section they belong to The sectioned list used one row per user with a priority rule (ignored > buddy > online), so an online buddy only appeared under "Buddies" and never in the "Online" list. Sections are now pure membership views: a user gets one row per section they belong to, so an online buddy appears under both "Online" and "Buddies". - Track rows per (section, user) in sectionUsers instead of reparenting a single row; the name->primary-row map is kept for external lookups. - Rebuild, presence and buddy/ignore mutations create/drop rows per section instead of moving a single row between sections. - Dropping one membership no longer removes the user from the other sections. * [UserList] Keyboard navigation for section dividers, popup on selection Section dividers were not selectable, so arrow-key navigation skipped them entirely, and the user popup only appeared on hover or click. Now: - Dividers are selectable, so Up/Down navigation lands on them; they act as collapsible headers once focused (Enter/Space toggle, Left/Right collapse/expand per tree convention), with a focus indicator drawn by the existing delegate. - The popup follows keyboard selection via currentItemChanged, exactly like mouse hover, and closes when the selection moves to a divider or leaves the list. - The popup anchors on the hovered/selected row instead of a user-name lookup, so with duplicate rows (online + buddy) it stays attached to the row under the mouse/cursor. - Left-arrow now actually collapses an expanded section divider: the collapse branch hardcoded the target expansion state to 'expanded', making the key a no-op. - The user popup no longer flashes through a fade when hopping between users (hover or arrow-key navigation): a content swap keeps it opaque, and pending show/hide timers are cancelled so an armed hover timer cannot override a keyboard-selected row or a pending hide kill the newly shown popup. - Bulk rebuild defers per-row divider-count updates to endBulkLoad(), removing the quadratic recount during large online-list loads. - handleOnlineChangeLeft/handleListRemove skip the sort+filter+repaint when nothing actually changed. * [UserList] Tune the role row gradient colors (dark parity, light mode) Dark mode is byte-for-byte the pre-branch painter profile, with the original saturated-left to navy-right fade restored verbatim. Light mode uses the same language at high tint strength: role rows get colored fades (0.75/0.65 left to 0.18/0.10 right), and regular users get flat warm paper cards (AlternateBase) instead of the grey slate. * [UserList] Deselect the list and close the popup on outside clicks Clicking anywhere outside the tree, the popup or an open menu now clears the selection and hides the popup, so a pinned popup does not stay open when the list loses focus. - The application-wide event filter watches every mouse press and treats a press as inside the list UI only when its target is the tree, the popup or an open menu (parent-chain walk), so a click on another list, a tab or the window background deselects. - A hover popup now also closes when the cursor leaves the hovered row. The hide timer previously checked whether the cursor was over the tree, which is always true over empty list space and section dividers, so the popup stayed open after moving off the user. - Deselection keeps the current item so keyboard navigation is not disturbed, and the pinned flag is dropped before hiding so the selection-changed handler does not hide twice. Took 15 minutes * [UserList] Use an enum for the list sections The section identifiers were stringly-typed: eleven hardcoded QStringLiteral comparisons scattered through user_list_widget.cpp, and the display path (sectionTitle) maps every id through tr() anyway, so the raw strings were never shown. A typo compiled fine and silently broke a section. - enum class Section { Buddy, Online, Ignore } replaces the section strings across the sectioned-list API (setSectioned, getSectionIds, setSectionExpanded, the sectionExpanded signal and all membership helpers), giving compile-time checks at every call site. - sectionTitle becomes a switch over the enum and the dead raw-string fallback is gone. - The expanded-section state persists the same stable keys via the panel widget boundary, so existing settings files survive unchanged. - The divider reverse lookup in handleSectionExpansion no longer relies on an empty-string sentinel from QMap::key; it scans the three dividers and bails when the item is not one of them. Took 12 minutes # Commit time for manual adjustment: # Took 2 minutes --------- Co-authored-by: Lukas Brübach --- cockatrice/CMakeLists.txt | 1 + cockatrice/src/interface/theme_manager.cpp | 2 +- cockatrice/src/interface/theme_manager.h | 9 +- .../widgets/server/user/user_info_popup.cpp | 422 ++++--- .../widgets/server/user/user_info_popup.h | 98 +- .../widgets/server/user/user_list_painter.cpp | 110 +- .../widgets/server/user/user_list_painter.h | 45 +- .../server/user/user_list_panel_widget.cpp | 88 ++ .../server/user/user_list_panel_widget.h | 43 + .../widgets/server/user/user_list_widget.cpp | 1081 ++++++++++++++--- .../widgets/server/user/user_list_widget.h | 77 +- .../src/interface/widgets/tabs/tab_room.cpp | 26 +- .../src/interface/widgets/tabs/tab_room.h | 4 +- .../interface_interface_settings_provider.h | 2 + .../settings/interface_settings.cpp | 11 + .../settings/interface_settings.h | 2 + 16 files changed, 1598 insertions(+), 423 deletions(-) create mode 100644 cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp create mode 100644 cockatrice/src/interface/widgets/server/user/user_list_panel_widget.h 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);