[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.
This commit is contained in:
Lukas Brübach 2026-08-15 13:32:07 +02:00
parent bb94c34c7e
commit ac09f4bbaf
2 changed files with 113 additions and 28 deletions

View file

@ -12,12 +12,14 @@
#include <QApplication> #include <QApplication>
#include <QCheckBox> #include <QCheckBox>
#include <QCursor>
#include <QFont> #include <QFont>
#include <QFontMetrics> #include <QFontMetrics>
#include <QFrame> #include <QFrame>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
#include <QInputDialog> #include <QInputDialog>
#include <QKeyEvent>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QMessageBox> #include <QMessageBox>
@ -614,8 +616,17 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
showPopupTimer->setSingleShot(true); showPopupTimer->setSingleShot(true);
showPopupTimer->setInterval(280); showPopupTimer->setInterval(280);
connect(showPopupTimer, &QTimer::timeout, this, [this] { connect(showPopupTimer, &QTimer::timeout, this, [this] {
if (!hoveredUser.isEmpty()) { if (hoveredUser.isEmpty()) {
showPopupForUser(hoveredUser); return;
}
// Re-resolve the row under the cursor: in sectioned mode a user can own
// several rows (online + buddy), so the popup must anchor to the exact
// hovered row instead of a name-based lookup.
const QPoint viewportPos = userTree->viewport()->mapFromGlobal(QCursor::pos());
QTreeWidgetItem *item = userTree->itemAt(viewportPos);
if (item && item->type() == QTreeWidgetItem::Type &&
QString::fromStdString(static_cast<UserListTWI *>(item)->getUserInfo().name()) == hoveredUser) {
showPopupForUser(static_cast<UserListTWI *>(item));
} }
}); });
@ -633,6 +644,7 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
userTree->setMouseTracking(true); userTree->setMouseTracking(true);
userTree->viewport()->setMouseTracking(true); userTree->viewport()->setMouseTracking(true);
userTree->viewport()->installEventFilter(this); userTree->viewport()->installEventFilter(this);
userTree->installEventFilter(this); // keyboard handling for section dividers
// Pin on item click // Pin on item click
connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) {
@ -648,9 +660,8 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
if (item->type() != QTreeWidgetItem::Type) { if (item->type() != QTreeWidgetItem::Type) {
return; // divider rows have no user popup return; // divider rows have no user popup
} }
const QString name = static_cast<UserListTWI *>(item)->getUserInfo().name().c_str();
popupPinned = false; // reset so showPopupForUser can update popupPinned = false; // reset so showPopupForUser can update
showPopupForUser(name); showPopupForUser(static_cast<UserListTWI *>(item));
popupPinned = true; // pin after showing popupPinned = true; // pin after showing
}); });
@ -663,6 +674,22 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
} }
}); });
// 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<UserListTWI *>(current));
} else {
popupPinned = false;
hidePopup();
}
});
// Section dividers can be collapsed/expanded by the user. Surface those // Section dividers can be collapsed/expanded by the user. Surface those
// changes (only from real user interaction — programmatic expansion is // changes (only from real user interaction — programmatic expansion is
// applied through setSectionExpanded() / setExpandedProgrammatically()). // applied through setSectionExpanded() / setExpandedProgrammatically()).
@ -760,9 +787,8 @@ void UserListWidget::bind(UserListManager *mgr)
// currently online user, the "Buddy"/"Ignore" sections hold those // currently online user, the "Buddy"/"Ignore" sections hold those
// lists. A user can therefore appear in several sections at once (an // lists. A user can therefore appear in several sections at once (an
// online buddy gets one row in each). // online buddy gets one row in each).
connect(manager, &UserListManager::userJoinedOnline, this, [this](const ServerInfo_User &user) { connect(manager, &UserListManager::userJoinedOnline, this,
handleOnlineChange(user); [this](const ServerInfo_User &user) { handleOnlineChange(user); });
});
connect(manager, &UserListManager::userLeftOnline, this, connect(manager, &UserListManager::userLeftOnline, this,
[this](const QString &name) { handleOnlineChangeLeft(name); }); [this](const QString &name) { handleOnlineChangeLeft(name); });
connect(manager, &UserListManager::addedToBuddyList, this, connect(manager, &UserListManager::addedToBuddyList, this,
@ -817,7 +843,7 @@ void UserListWidget::refreshPopupButtons(const QString &userName)
const bool isIgn = proxy->isUserIgnored(userName); const bool isIgn = proxy->isUserIgnored(userName);
userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn); userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn);
positionPopup(userName); // height may have changed — reposition positionPopup(item); // height may have changed — reposition
} }
void UserListWidget::hideEvent(QHideEvent *e) void UserListWidget::hideEvent(QHideEvent *e)
@ -893,6 +919,27 @@ void UserListWidget::connectPopupSignals()
bool UserListWidget::eventFilter(QObject *obj, QEvent *event) bool UserListWidget::eventFilter(QObject *obj, QEvent *event)
{ {
// ── 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<QKeyEvent *>(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 (obj == userTree->viewport()) {
if (event->type() == QEvent::MouseMove) { if (event->type() == QEvent::MouseMove) {
if (!SettingsCache::instance().appearance().getStyleUserList()) { if (!SettingsCache::instance().appearance().getStyleUserList()) {
@ -931,13 +978,13 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event)
return QGroupBox::eventFilter(obj, event); return QGroupBox::eventFilter(obj, event);
} }
void UserListWidget::showPopupForUser(const QString &userName) void UserListWidget::showPopupForUser(UserListTWI *item)
{ {
UserListTWI *item = users.value(userName);
if (!item) { if (!item) {
return; return;
} }
const QString userName = QString::fromStdString(item->getUserInfo().name());
avatarProvider->requestAvatar(userName); // ensure the hovered user's avatar is fetched promptly avatarProvider->requestAvatar(userName); // ensure the hovered user's avatar is fetched promptly
const ServerInfo_User &info = item->getUserInfo(); const ServerInfo_User &info = item->getUserInfo();
@ -945,18 +992,41 @@ void UserListWidget::showPopupForUser(const QString &userName)
const bool isBuddy = userContextMenu->getUserListProxy()->isUserBuddy(userName); const bool isBuddy = userContextMenu->getUserListProxy()->isUserBuddy(userName);
const bool isIgn = userContextMenu->getUserListProxy()->isUserIgnored(userName); const bool isIgn = userContextMenu->getUserListProxy()->isUserIgnored(userName);
// The popup is already showing this user (e.g. arrow-key navigation between
// the online/buddy rows of the same user): just re-anchor it.
if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == userName) {
positionPopup(item);
return;
}
// Cancel any pending show/hide so a hover timer that armed before a
// keyboard-selected row cannot override it, and a pending hide cannot kill
// the popup right after it appears.
showPopupTimer->stop();
hidePopupTimer->stop();
userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn); userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn);
// Realize the native window at opacity 0 before positioning so that: const bool wasVisible = userInfoPopup->isVisible();
// 1) move() applies to an existing native handle (not overridden by Qt's if (!wasVisible) {
// default centering logic on first show) // Realize the native window at opacity 0 before positioning so that:
// 2) adjustSize() inside positionPopup() can measure the final laid-out // 1) move() applies to an existing native handle (not overridden by
// geometry correctly // Qt's default centering logic on first show)
userInfoPopup->setWindowOpacity(0.0); // 2) adjustSize() inside positionPopup() can measure the final
// laid-out geometry correctly
userInfoPopup->setWindowOpacity(0.0);
}
userInfoPopup->show(); userInfoPopup->show();
userInfoPopup->raise(); userInfoPopup->raise();
positionPopup(userName); // geometry is now accurate; move() sticks positionPopup(item); // geometry is now accurate; move() sticks
if (wasVisible) {
// Content swap while already open (hover/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); auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup);
fade->setDuration(120); fade->setDuration(120);
@ -965,9 +1035,8 @@ void UserListWidget::showPopupForUser(const QString &userName)
fade->start(QAbstractAnimation::DeleteWhenStopped); fade->start(QAbstractAnimation::DeleteWhenStopped);
} }
void UserListWidget::positionPopup(const QString &userName) void UserListWidget::positionPopup(UserListTWI *item)
{ {
UserListTWI *item = users.value(userName);
if (!item) { if (!item) {
return; return;
} }
@ -1071,6 +1140,7 @@ void UserListWidget::endBulkLoad()
{ {
bulkLoading = false; bulkLoading = false;
sortItems(); sortItems();
updateCount(); // divider counts were deferred during the bulk build
requestAvatarsForVisibleItems(); requestAvatarsForVisibleItems();
userTree->viewport()->update(); userTree->viewport()->update();
} }
@ -1486,7 +1556,9 @@ QTreeWidgetItem *UserListWidget::createSectionItem(const QString &sectionId)
{ {
Q_UNUSED(sectionId); Q_UNUSED(sectionId);
auto *divider = new QTreeWidgetItem(SectionItemType); auto *divider = new QTreeWidgetItem(SectionItemType);
divider->setFlags(Qt::ItemIsEnabled); // 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(); QFont font = userTree->font();
font.setBold(true); font.setBold(true);
@ -1600,11 +1672,14 @@ void UserListWidget::handleOnlineChangeLeft(const QString &userName)
// The user is no longer online: their "Online" row disappears. Buddies and // 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 // ignored users keep their own section's row, marked offline; a plain user
// has no rows left. // has no rows left.
dropSectionMembership(QStringLiteral("online"), userName); const bool dropped = dropSectionMembership(QStringLiteral("online"), userName);
if (manager->isUserBuddy(userName) || manager->isUserIgnored(userName)) { const bool kept = manager->isUserBuddy(userName) || manager->isUserIgnored(userName);
if (kept) {
setUserOnline(userName, false); setUserOnline(userName, false);
} }
finishSectionedMutation(); if (dropped || kept) {
finishSectionedMutation();
}
} }
void UserListWidget::handleListAdd(const QString &sectionId, const ServerInfo_User &user) void UserListWidget::handleListAdd(const QString &sectionId, const ServerInfo_User &user)
@ -1624,8 +1699,9 @@ void UserListWidget::handleListRemove(const QString &sectionId, const QString &u
{ {
// Only the row of the removed section disappears: an online user keeps // Only the row of the removed section disappears: an online user keeps
// their "Online" row, and other list memberships keep theirs. // their "Online" row, and other list memberships keep theirs.
dropSectionMembership(sectionId, userName); if (dropSectionMembership(sectionId, userName)) {
finishSectionedMutation(); finishSectionedMutation();
}
} }
UserListTWI *UserListWidget::ensureSectionMembership(const QString &sectionId, const ServerInfo_User &user, bool online) UserListTWI *UserListWidget::ensureSectionMembership(const QString &sectionId, const ServerInfo_User &user, bool online)
@ -1648,7 +1724,11 @@ UserListTWI *UserListWidget::ensureSectionMembership(const QString &sectionId, c
if (!users.contains(userName)) { if (!users.contains(userName)) {
users.insert(userName, item); // primary row for name-based lookups users.insert(userName, item); // primary row for name-based lookups
} }
updateCount(); // a new row changes the divider's count // The divider counts are refreshed once in endBulkLoad(); per-row
// updateCount() during a large rebuild would be quadratic.
if (!bulkLoading) {
updateCount(); // a new row changes the divider's count
}
if (!bulkLoading && isItemNearViewport(item)) { if (!bulkLoading && isItemNearViewport(item)) {
avatarProvider->requestAvatar(userName); avatarProvider->requestAvatar(userName);
} }

View file

@ -164,9 +164,14 @@ private:
bool popupPinned = false; bool popupPinned = false;
bool bulkLoading = 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 name-based lookup.
*/
void showPopupForUser(UserListTWI *item);
void hidePopup(bool immediate = false); void hidePopup(bool immediate = false);
void positionPopup(const QString &userName); void positionPopup(UserListTWI *item);
void connectPopupSignals(); void connectPopupSignals();
bool isItemNearViewport(const UserListTWI *item) const; bool isItemNearViewport(const UserListTWI *item) const;
void requestAvatarsForVisibleItems(); void requestAvatarsForVisibleItems();