More granular signals, popup for user info.

Took 25 minutes

Took 8 seconds

Took 16 minutes
This commit is contained in:
Lukas Brübach 2026-06-16 09:25:01 +02:00
parent 8007d40a90
commit bf04a5b86a
6 changed files with 246 additions and 67 deletions

View file

@ -329,6 +329,9 @@ void UserInfoPopup::buildUi()
m_gamesView->setMaximumHeight(220); m_gamesView->setMaximumHeight(220);
m_gamesView->setStyleSheet(QStringLiteral("QListView{background:#0e1218;border:none;}" m_gamesView->setStyleSheet(QStringLiteral("QListView{background:#0e1218;border:none;}"
"QListView::item:selected{background:#232e42;}")); "QListView::item:selected{background:#232e42;}"));
m_gamesView->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu);
root->addWidget(m_gamesView); root->addWidget(m_gamesView);
// Close button — positioned absolutely in the top-right corner // Close button — positioned absolutely in the top-right corner
@ -477,6 +480,51 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
m_actionArea->adjustSize(); m_actionArea->adjustSize();
} }
void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored)
{
rebuildActionButtons(userInfo, online, isBuddy, isIgnored);
adjustSize();
}
void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
{
const QModelIndex idx = m_gamesView->indexAt(pos);
if (!idx.isValid()) {
return;
}
const QVariant var = idx.data(PopupRoles::GameData);
if (!var.isValid()) {
return;
}
const ServerInfo_Game game = var.value<ServerInfo_Game>();
QMenu menu(this);
menu.setStyleSheet(
QStringLiteral("QMenu{background:#12182a;color:#c8d8ec;border:1px solid #1e2838;border-radius:4px;}"
"QMenu::item:selected{background:#223050;}"));
const bool canJoin = !game.started() && game.player_count() < game.max_players();
QAction *join = menu.addAction(tr("Join game"));
join->setEnabled(canJoin);
QAction *spec = nullptr;
if (game.spectators_allowed()) {
spec = menu.addAction(tr("Spectate"));
}
const QAction *chosen = menu.exec(m_gamesView->viewport()->mapToGlobal(pos));
if (!chosen) {
return;
}
if (chosen == join) {
emit joinGameRequested(game.game_id(), game.room_id(), false);
} else if (spec && chosen == spec) {
emit joinGameRequested(game.game_id(), game.room_id(), true);
}
}
// ── showForUser ─────────────────────────────────────────────────────────────── // ── showForUser ───────────────────────────────────────────────────────────────
void UserInfoPopup::showForUser(const QString &userName, void UserInfoPopup::showForUser(const QString &userName,
@ -565,6 +613,15 @@ void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser)
m_gamesStatus->hide(); m_gamesStatus->hide();
m_gamesView->show(); m_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 visible = qMin(count, maxRows);
m_gamesView->setFixedHeight(visible * rowH + 2);
m_gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff);
adjustSize(); adjustSize();
} }

View file

@ -113,11 +113,17 @@ public:
return m_currentUser; return m_currentUser;
} }
/** Called when buddy/ignore status changes externally while popup is open. */
void updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored);
signals: signals:
void mouseEnteredPopup(); void mouseEnteredPopup();
void mouseLeftPopup(); void mouseLeftPopup();
void closeRequested(); void closeRequested();
/** Emitted when the user requests joining or spectating a game in the list. */
void joinGameRequested(int gameId, int roomId, bool asSpectator);
// ── Action signals — connect to UserContextMenu::exec*() ────────────────── // ── Action signals — connect to UserContextMenu::exec*() ──────────────────
void chatRequested(const QString &userName); void chatRequested(const QString &userName);
void detailsRequested(const QString &userName); void detailsRequested(const QString &userName);
@ -147,6 +153,7 @@ protected:
private slots: private slots:
void refreshGames(); void refreshGames();
void onGamesReceived(const Response &r, const QString &forUser); void onGamesReceived(const Response &r, const QString &forUser);
void onGamesContextMenu(const QPoint &pos);
private: private:
void buildUi(); void buildUi();

View file

@ -42,7 +42,9 @@ void UserListManager::handleDisconnect()
delete ownUserInfo; delete ownUserInfo;
ownUserInfo = nullptr; ownUserInfo = nullptr;
emit listsChanged();
// Full rebuild — all lists are gone
emit listReset();
} }
void UserListManager::setOwnUserInfo(const ServerInfo_User &userInfo) void UserListManager::setOwnUserInfo(const ServerInfo_User &userInfo)
@ -64,81 +66,77 @@ void UserListManager::processListUsersResponse(const Response &response)
const int userListSize = resp.user_list_size(); const int userListSize = resp.user_list_size();
for (int i = 0; i < userListSize; ++i) { for (int i = 0; i < userListSize; ++i) {
const ServerInfo_User &info = resp.user_list(i); const ServerInfo_User &info = resp.user_list(i);
const QString &userName = QString::fromStdString(info.name()); onlineUsers.insert(QString::fromStdString(info.name()), info);
onlineUsers.insert(userName, info);
} }
emit listsChanged();
// Bulk load complete — widgets rebuild once from the now-populated map
emit listReset();
} }
void UserListManager::processUserJoinedEvent(const Event_UserJoined &event) void UserListManager::processUserJoinedEvent(const Event_UserJoined &event)
{ {
const auto &info = event.user_info(); const auto &info = event.user_info();
const QString &userName = QString::fromStdString(info.name()); const QString name = QString::fromStdString(info.name());
onlineUsers.insert(userName, info); onlineUsers.insert(name, info);
emit listsChanged();
emit userJoinedOnline(info);
} }
void UserListManager::processUserLeftEvent(const Event_UserLeft &event) void UserListManager::processUserLeftEvent(const Event_UserLeft &event)
{ {
const auto &userName = QString::fromStdString(event.name()); const QString name = QString::fromStdString(event.name());
onlineUsers.remove(userName); onlineUsers.remove(name);
emit listsChanged();
emit userLeftOnline(name);
} }
void UserListManager::buddyListReceived(const QList<ServerInfo_User> &_buddyList) void UserListManager::buddyListReceived(const QList<ServerInfo_User> &_buddyList)
{ {
for (const auto &user : _buddyList) { for (const auto &user : _buddyList) {
const auto &userName = QString::fromStdString(user.name()); buddyUsers.insert(QString::fromStdString(user.name()), user);
buddyUsers.insert(userName, user);
emit listsChanged();
} }
// Bulk load — one reset covers all newly added entries
emit listReset();
} }
void UserListManager::ignoreListReceived(const QList<ServerInfo_User> &_ignoreList) void UserListManager::ignoreListReceived(const QList<ServerInfo_User> &_ignoreList)
{ {
for (const auto &user : _ignoreList) { for (const auto &user : _ignoreList) {
const auto &userName = QString::fromStdString(user.name()); ignoredUsers.insert(QString::fromStdString(user.name()), user);
ignoredUsers.insert(userName, user);
emit listsChanged();
} }
// Bulk load — one reset covers all newly added entries
emit listReset();
} }
void UserListManager::processAddToListEvent(const Event_AddToList &event) void UserListManager::processAddToListEvent(const Event_AddToList &event)
{ {
const auto &user = event.user_info(); const auto &user = event.user_info();
const auto &userName = QString::fromStdString(user.name()); const QString userName = QString::fromStdString(user.name());
const QString listType = QString::fromStdString(event.list_name());
const auto &userListType = QString::fromStdString(event.list_name()); if (listType == "buddy") {
buddyUsers.insert(userName, user);
QMap<QString, ServerInfo_User> *userMap; emit addedToBuddyList(user);
if (userListType == "buddy") { } else if (listType == "ignore") {
userMap = &buddyUsers; ignoredUsers.insert(userName, user);
} else if (userListType == "ignore") { emit addedToIgnoreList(user);
userMap = &ignoredUsers;
} else {
return;
} }
userMap->insert(userName, user);
emit listsChanged();
} }
void UserListManager::processRemoveFromListEvent(const Event_RemoveFromList &event) void UserListManager::processRemoveFromListEvent(const Event_RemoveFromList &event)
{ {
const auto &userListType = QString::fromStdString(event.list_name()); const QString listType = QString::fromStdString(event.list_name());
const auto &userName = QString::fromStdString(event.user_name()); const QString userName = QString::fromStdString(event.user_name());
QMap<QString, ServerInfo_User> *userMap; if (listType == "buddy") {
if (userListType == "buddy") { buddyUsers.remove(userName);
userMap = &buddyUsers; emit removedFromBuddyList(userName);
} else if (userListType == "ignore") { } else if (listType == "ignore") {
userMap = &ignoredUsers; ignoredUsers.remove(userName);
} else { emit removedFromIgnoreList(userName);
return;
} }
userMap->remove(userName);
emit listsChanged();
} }
bool UserListManager::isOwnUserRegistered() const bool UserListManager::isOwnUserRegistered() const
@ -163,16 +161,9 @@ bool UserListManager::isUserIgnored(const QString &userName) const
const ServerInfo_User *UserListManager::getOnlineUser(const QString &userName) const const ServerInfo_User *UserListManager::getOnlineUser(const QString &userName) const
{ {
const QString &userNameToMatchLower = userName.toLower(); const QString lower = userName.toLower();
const auto it = std::find_if(onlineUsers.begin(), onlineUsers.end(), [&lower](const ServerInfo_User &user) {
const auto it = return lower == QString::fromStdString(user.name()).toLower();
std::find_if(onlineUsers.begin(), onlineUsers.end(), [&userNameToMatchLower](const ServerInfo_User &user) { });
return userNameToMatchLower == QString::fromStdString(user.name()).toLower(); return it != onlineUsers.end() ? &*it : nullptr;
});
if (it != onlineUsers.end()) {
return &*it;
}
return nullptr;
} }

View file

@ -73,9 +73,26 @@ public slots:
void handleDisconnect(); void handleDisconnect();
signals: signals:
void userLeft(const QString &userName); /**
void userJoined(const ServerInfo_User &userInfo); * The entire list needs to be rebuilt from scratch.
void listsChanged(); * Fired on disconnect, reconnect, and initial bulk loads
* (Command_ListUsers response, initial buddy/ignore lists).
*/
void listReset();
// ── Online user presence ──────────────────────────────────────────────────
/** A user came online (or joined the room). Full ServerInfo_User available. */
void userJoinedOnline(const ServerInfo_User &user);
/** A user went offline (or left the room). */
void userLeftOnline(const QString &userName);
// ── Buddy list mutations (individual, post-login) ─────────────────────────
void addedToBuddyList(const ServerInfo_User &user);
void removedFromBuddyList(const QString &userName);
// ── Ignore list mutations (individual, post-login) ────────────────────────
void addedToIgnoreList(const ServerInfo_User &user);
void removedFromIgnoreList(const QString &userName);
}; };
#endif // COCKATRICE_USER_LIST_MANAGER_H #endif // COCKATRICE_USER_LIST_MANAGER_H

View file

@ -533,15 +533,24 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
m_popupPinned = true; // pin after showing m_popupPinned = true; // pin after showing
}); });
// Unpin when selection cleared
connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this,
[this](const QItemSelection &sel, const QItemSelection &) { [this](const QItemSelection &sel, const QItemSelection &) {
// if (m_rebuildingTree) return;
if (sel.isEmpty() && m_popupPinned) { if (sel.isEmpty() && m_popupPinned) {
m_popupPinned = false; m_popupPinned = false;
hidePopup(); hidePopup();
} }
}); });
// Hide popup when list scrolls (reference row has moved)
connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] {
m_showPopupTimer->stop();
hidePopup(true);
});
// Forward join requests from popup upward
connect(m_userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested);
connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this,
[this](const QString &) { userTree->viewport()->update(); }); [this](const QString &) { userTree->viewport()->update(); });
connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this,
@ -562,11 +571,94 @@ void UserListWidget::bind(UserListManager *mgr)
{ {
manager = mgr; manager = mgr;
connect(manager, &UserListManager::listsChanged, this, &UserListWidget::rebuild); // ── Full rebuild: disconnect / reconnect / bulk initial load ──────────────
connect(manager, &UserListManager::listReset, this, &UserListWidget::rebuild);
// ── 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); });
}
// ── 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) {
refreshPopupButtons(name);
}
};
auto refreshCurrentPopup = [refreshIfPopupOpen](const ServerInfo_User &u) {
refreshIfPopupOpen(QString::fromStdString(u.name()));
};
connect(manager, &UserListManager::addedToBuddyList, this, refreshCurrentPopup);
connect(manager, &UserListManager::removedFromBuddyList, this, refreshIfPopupOpen);
connect(manager, &UserListManager::addedToIgnoreList, this, refreshCurrentPopup);
connect(manager, &UserListManager::removedFromIgnoreList, this, refreshIfPopupOpen);
connect(manager, &UserListManager::userJoinedOnline, this, refreshCurrentPopup);
connect(manager, &UserListManager::userLeftOnline, this, refreshIfPopupOpen);
rebuild(); rebuild();
} }
void UserListWidget::refreshPopupButtons(const QString &userName)
{
UserListTWI *item = users.value(userName);
if (!item) {
return;
}
const UserListProxy *proxy = tabSupervisor->getUserListManager();
const bool online = item->data(0, UserListRoles::Online).toBool();
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
}
void UserListWidget::hideEvent(QHideEvent *e)
{
QGroupBox::hideEvent(e);
m_showPopupTimer->stop();
m_hidePopupTimer->stop();
hidePopup(true);
}
void UserListWidget::applyDisplayMode() void UserListWidget::applyDisplayMode()
{ {
const bool styled = SettingsCache::instance().getStyleUserList(); const bool styled = SettingsCache::instance().getStyleUserList();
@ -698,23 +790,32 @@ void UserListWidget::positionPopup(const QString &userName)
QWidget *vp = userTree->viewport(); QWidget *vp = userTree->viewport();
const QRect itemR = userTree->visualItemRect(item); const QRect itemR = userTree->visualItemRect(item);
const QPoint itemTL = vp->mapToGlobal(itemR.topLeft()); const QPoint itemBR = vp->mapToGlobal(itemR.bottomRight());
const QPoint vpTL = vp->mapToGlobal(vp->rect().topLeft()); const QPoint vpTL = vp->mapToGlobal(vp->rect().topLeft());
const QPoint vpTR = vp->mapToGlobal(vp->rect().topRight());
// Force a fresh size calculation so popH is accurate
m_userInfoPopup->adjustSize();
const int popW = m_userInfoPopup->width(); const int popW = m_userInfoPopup->width();
const int popH = m_userInfoPopup->sizeHint().height(); const int popH = m_userInfoPopup->height();
const int margin = 12; const int margin = 12;
// Go left of the list if there's room, otherwise right
int x =
(vpTL.x() >= popW + margin) ? vpTL.x() - popW - margin : vp->mapToGlobal(vp->rect().topRight()).x() + margin;
// Align top with the hovered row, clamped to available screen space
const QRect screen = QGuiApplication::primaryScreen()->availableGeometry(); const QRect screen = QGuiApplication::primaryScreen()->availableGeometry();
int y = itemTL.y();
y = qMin(y, screen.bottom() - popH - margin); // ── X: left of the list if there's room, otherwise right ─────────────────
int x = (vpTL.x() >= popW + margin) ? vpTL.x() - popW - margin : vpTR.x() + margin;
x = qBound(screen.left() + margin, x, screen.right() - popW - margin);
// ── Y: bottom of popup aligns with bottom of hovered row, grows upward ───
int y = itemBR.y() - popH;
// Clamp: never above the screen top
y = qMax(y, screen.top() + margin); y = qMax(y, screen.top() + margin);
// Clamp: never below the screen bottom (e.g. if the popup is taller
// than the space above the row, let it spill downward rather than clip)
y = qMin(y, screen.bottom() - popH - margin);
m_userInfoPopup->move(x, y); m_userInfoPopup->move(x, y);
} }
@ -827,6 +928,7 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online)
avatarProvider->requestAvatar(userName); avatarProvider->requestAvatar(userName);
} }
item->setOnline(online); item->setOnline(online);
sortItems();
userTree->viewport()->update(); userTree->viewport()->update();
} }

View file

@ -174,6 +174,7 @@ private:
int onlineCount; int onlineCount;
QString titleStr; QString titleStr;
void updateCount(); void updateCount();
void refreshPopupButtons(const QString &userName);
private slots: private slots:
void userClicked(QTreeWidgetItem *item, int column); void userClicked(QTreeWidgetItem *item, int column);
signals: signals:
@ -182,6 +183,7 @@ signals:
void removeBuddy(const QString &userName); void removeBuddy(const QString &userName);
void addIgnore(const QString &userName); void addIgnore(const QString &userName);
void removeIgnore(const QString &userName); void removeIgnore(const QString &userName);
void joinGameRequested(int gameId, int roomId, bool asSpectator);
public: public:
UserListWidget(TabSupervisor *_tabSupervisor, UserListWidget(TabSupervisor *_tabSupervisor,
@ -202,6 +204,9 @@ public:
} }
void showContextMenu(const QPoint &pos, const QModelIndex &index); void showContextMenu(const QPoint &pos, const QModelIndex &index);
void sortItems(); void sortItems();
protected:
void hideEvent(QHideEvent *e) override;
}; };
#endif #endif