[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.
This commit is contained in:
Lukas Brübach 2026-08-15 21:50:12 +02:00
parent ac09f4bbaf
commit 75142e6e9f
6 changed files with 91 additions and 76 deletions

View file

@ -322,7 +322,7 @@ UserInfoPopup::UserInfoPopup(TabSupervisor *_ts,
setFrameShape(QFrame::NoFrame);
buildUi();
// Re-theme the popup chrome when the theme or its color scheme changes.
// Restyle the popup chrome when the theme or its color scheme changes.
if (themeManager) {
connect(themeManager, &ThemeManager::themeChanged, this, &UserInfoPopup::applyTheme);
}

View file

@ -26,11 +26,11 @@ namespace PopupRoles
constexpr int GameData = Qt::UserRole + 10;
}
// ── Popup theme ───────────────────────────────────────────────────────────────
// 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
* the active QPalette so custom palettes are respected. @c dark only tunes the
* blend strengths.
*/
struct PopupTheme

View file

@ -84,28 +84,40 @@ void UserListPainter::drawBackground(QPainter *painter,
const QRectF &cardRect,
const QColor &accentColor,
bool selected,
const Style &style)
const Style &style,
bool hasRole)
{
QLinearGradient bg(cardRect.topLeft(), cardRect.topRight());
if (selected) {
bg.setColorAt(0, blend(style.cardStart, accentColor, 0.20));
bg.setColorAt(1, blend(style.cardEnd, accentColor, 0.13));
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 {
// Gentle accent tint so a role's color stays visible on unselected
// cards without shouting over the list. Light cards need a slightly
// stronger blend because the base is near-white.
const qreal startT = style.dark ? 0.12 : 0.16;
const qreal endT = style.dark ? 0.08 : 0.10;
bg.setColorAt(0, blend(style.cardStart, accentColor, startT));
bg.setColorAt(1, blend(style.cardEnd, accentColor, endT));
// 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)
@ -353,7 +365,7 @@ void UserListPainter::paint(QPainter *painter,
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
// 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;
@ -366,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);
@ -373,7 +388,7 @@ void UserListPainter::paint(QPainter *painter,
? cardArtParamsMap->value(userName)
: CardArtParams{};
drawBackground(painter, cardRect, accentColor, selected, style);
drawBackground(painter, cardRect, accentColor, selected, style, hasRole);
drawCardArt(painter, rect, cardRight, userName, cardArtCache, params);
const QRect avatarRect = getAvatarRect(rect);

View file

@ -32,7 +32,7 @@ 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
* palettes are respected. @c dark only tunes the blend strengths (and
* whether the name text keeps its drop shadow).
*/
struct Style
@ -83,7 +83,8 @@ private:
const QRectF &cardRect,
const QColor &accentColor,
bool selected,
const Style &style);
const Style &style,
bool hasRole);
static QRect getAvatarRect(const QRect &rect);
static void drawAvatar(QPainter *painter,
const QRect &avatarRect,

View file

@ -373,7 +373,7 @@ QSize UserListItemDelegate::sizeHint(const QStyleOptionViewItem &option, const Q
void UserListItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
const bool styled = SettingsCache::instance().appearance().getStyleUserList();
// UserInfo/Online are stored on column 0 only; the name lives on column 2,
// 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);
@ -391,9 +391,9 @@ void UserListItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &
}
// 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 per-item brushes all go
// stale after a runtime theme change.
// 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;
@ -405,11 +405,11 @@ void UserListItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &
} else if (hovered) {
bg = UserListPainter::blend(appPal.color(QPalette::Base), appPal.color(QPalette::Highlight), 0.12);
} else if (var.isValid()) {
// Zebra with per-section parity: the dividers are top-level rows too,
// 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
// 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;
@ -429,9 +429,9 @@ void UserListItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &
bg = appPal.color(QPalette::AlternateBase);
}
}
// Paint the row background. In the column-0 pass the fill spans the full
// 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
// 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) {
@ -619,9 +619,9 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
if (hoveredUser.isEmpty()) {
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.
// 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 &&
@ -667,7 +667,6 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this,
[this](const QItemSelection &sel, const QItemSelection &) {
// if (m_rebuildingTree) return;
if (sel.isEmpty() && popupPinned) {
popupPinned = false;
hidePopup();
@ -676,7 +675,7 @@ 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.
// 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()) {
@ -691,8 +690,8 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor,
});
// Section dividers can be collapsed/expanded by the user. Surface those
// changes (only from real user interaction — programmatic expansion is
// applied through setSectionExpanded() / setExpandedProgrammatically()).
// 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,
@ -742,14 +741,14 @@ void UserListWidget::bind(UserListManager *mgr)
connect(manager, &UserListManager::listReset, this, &UserListWidget::rebuild);
if (!sectioned) {
// ── Online users list (AllUsersList / RoomList) ───────────────────────
// 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 ────────────────────────────────────────────────────────
// Buddy list
if (type == BuddyList) {
connect(manager, &UserListManager::addedToBuddyList, this, [this](const ServerInfo_User &user) {
const QString name = QString::fromStdString(user.name());
@ -772,7 +771,7 @@ void UserListWidget::bind(UserListManager *mgr)
});
}
// ── Ignore list ───────────────────────────────────────────────────────
// Ignore list
if (type == IgnoreList) {
connect(manager, &UserListManager::addedToIgnoreList, this, [this](const ServerInfo_User &user) {
const QString name = QString::fromStdString(user.name());
@ -782,7 +781,7 @@ void UserListWidget::bind(UserListManager *mgr)
[this](const QString &name) { deleteUser(name); });
}
} else {
// ── Sectioned mode: one tree, every source feeds its own section. ─────
// 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
@ -843,7 +842,7 @@ void UserListWidget::refreshPopupButtons(const QString &userName)
const bool isIgn = proxy->isUserIgnored(userName);
userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn);
positionPopup(item); // height may have changed — reposition
positionPopup(item); // height may have changed, reposition
}
void UserListWidget::hideEvent(QHideEvent *e)
@ -919,8 +918,8 @@ void UserListWidget::connectPopupSignals()
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
// 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) {
@ -992,16 +991,16 @@ void UserListWidget::showPopupForUser(UserListTWI *item)
const bool isBuddy = userContextMenu->getUserListProxy()->isUserBuddy(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.
// 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;
}
// 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.
// 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();
@ -1013,17 +1012,18 @@ void UserListWidget::showPopupForUser(UserListTWI *item)
// 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
// laid out geometry correctly
userInfoPopup->setWindowOpacity(0.0);
}
userInfoPopup->show();
userInfoPopup->raise();
positionPopup(item); // geometry is now accurate; move() sticks
positionPopup(item); // geometry is accurate after show, so move() is not overridden
if (wasVisible) {
// Content swap while already open (hover/arrow-key navigation): keep
// the popup opaque instead of flashing through a fade on every step.
// 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;
}
@ -1157,7 +1157,7 @@ bool UserListWidget::isItemNearViewport(const UserListTWI *item) const
void UserListWidget::requestAvatarsForVisibleItems()
{
if (sectioned) {
// Top-level items are dividers, user rows hang below them.
// Top level items are dividers, user rows hang below them.
for (const QString &sectionId : sectionIds) {
QTreeWidgetItem *divider = sectionItems.value(sectionId);
if (!divider) {
@ -1198,7 +1198,7 @@ void UserListWidget::rebuild()
}
if (sectioned) {
// Every source feeds its own section; users that belong to several
// 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();
@ -1245,7 +1245,7 @@ void UserListWidget::rebuild()
void UserListWidget::updateCardArtParams(const ServerInfo_User &user, const QString &userName)
{
// 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;
@ -1304,7 +1304,7 @@ void UserListWidget::processUserInfo(const QString &sectionId, const ServerInfo_
bool UserListWidget::deleteUser(const QString &userName)
{
if (sectioned) {
// The user may own several rows (one per section); drop them all.
// The user may own several rows (one per section). Drop them all.
bool removed = false;
const QStringList sections = sectionUsers.keys(); // snapshot: maps mutate
for (const QString &sectionId : sections) {
@ -1342,7 +1342,7 @@ 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
// 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);
@ -1358,9 +1358,9 @@ void UserListWidget::setUserOnline(const QString &userName, bool online)
return;
}
// No state change: nothing to re-sort. This also keeps the per-user
// presence broadcasts (userJoinedOnline fires for every online user) cheap
// when the row already carries the right flag.
// 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;
}
@ -1374,8 +1374,7 @@ void UserListWidget::setUserOnline(const QString &userName, bool online)
updateCount();
// Online users sort above offline users (UserListTWI::operator<), so a
// flag change moves the row: re-sort so e.g. a buddy who went offline no
// longer stays pinned at the top of the list.
// flag change moves the row. Resort to place the user by the new state.
if (!bulkLoading) {
sortItems();
applyFilter();
@ -1499,7 +1498,7 @@ void UserListWidget::sortItems()
{
if (sectioned) {
// Sorting must stay inside each section so the dividers keep their
// places as top-level items.
// places as top level items.
for (auto it = sectionItems.cbegin(); it != sectionItems.cend(); ++it) {
it.value()->sortChildren(0, Qt::AscendingOrder);
}
@ -1508,7 +1507,7 @@ void UserListWidget::sortItems()
userTree->sortItems(0, Qt::AscendingOrder);
}
// ── Sectioned mode ──────────────────────────────────────────────────────────
// Sectioned mode
void UserListWidget::setSectioned(const QStringList &ids)
{
@ -1556,19 +1555,19 @@ QTreeWidgetItem *UserListWidget::createSectionItem(const QString &sectionId)
{
Q_UNUSED(sectionId);
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.
// 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.
// separator without matching the full user row height.
divider->setSizeHint(0, QSize(0, QFontMetrics(font).height() + 16));
userTree->addTopLevelItem(divider);
// QTreeWidgetItem::setFirstColumnSpanned() is a no-op while the item is
// 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.
@ -1670,7 +1669,7 @@ void UserListWidget::handleOnlineChange(const ServerInfo_User &user)
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
// ignored users keep their own section's row, marked offline. A plain user
// has no rows left.
const bool dropped = dropSectionMembership(QStringLiteral("online"), userName);
const bool kept = manager->isUserBuddy(userName) || manager->isUserIgnored(userName);
@ -1688,7 +1687,7 @@ void UserListWidget::handleListAdd(const QString &sectionId, const ServerInfo_Us
const bool online = manager->getOnlineUser(name) != nullptr;
ensureSectionMembership(sectionId, user, online);
if (online) {
// The user belongs to the "Online" section as well; make sure the row
// 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(QStringLiteral("online"), user, true);
}
@ -1722,10 +1721,10 @@ UserListTWI *UserListWidget::ensureSectionMembership(const QString &sectionId, c
sectionMap.insert(userName, item);
divider->addChild(item);
if (!users.contains(userName)) {
users.insert(userName, item); // primary row for name-based lookups
users.insert(userName, item); // primary row for lookups by name
}
// The divider counts are refreshed once in endBulkLoad(); per-row
// updateCount() during a large rebuild would be quadratic.
// 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
}

View file

@ -165,9 +165,9 @@ private:
bool bulkLoading = false;
/**
* Popup functions are anchored on the row, not the user name: in sectioned
* 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.
* follow the hovered/selected row rather than a lookup by name.
*/
void showPopupForUser(UserListTWI *item);
void hidePopup(bool immediate = false);
@ -176,7 +176,7 @@ private:
bool isItemNearViewport(const UserListTWI *item) const;
void requestAvatarsForVisibleItems();
// ── Sectioned mode (single tree with inline dividers) ────────────────────
// Sectioned mode (single tree with inline dividers)
bool sectioned = false;
QStringList sectionIds;
QMap<QString, QTreeWidgetItem *> sectionItems;
@ -199,7 +199,7 @@ private:
UserListTWI *ensureSectionMembership(const QString &sectionId, const ServerInfo_User &user, bool online);
/** Removes and deletes the row for @p userName in @p sectionId. */
bool dropSectionMembership(const QString &sectionId, const QString &userName);
/** Sorts, re-filters and repaints after a sectioned-mode mutation. */
/** Sorts, refilters and repaints after a sectioned mode mutation. */
void finishSectionedMutation();
void updateCardArtParams(const ServerInfo_User &user, const QString &userName);
void processUserInfo(const QString &sectionId, const ServerInfo_User &user, bool online);