From 4d645e75133cadb3a658062559a9be248be60966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 05:29:12 +0100 Subject: [PATCH 1/8] Fix remaining review threads on the report queue dashboard - Read the raw report status from Qt::UserRole in DlgMyReports, so regular users can comment on their own open reports again. - Exclude the server's depth-0 seed row from the related-alt count. - Dim only IP matches shared by more than the pair, not every IP match. - Activate alts rows on double-click or Enter instead of `activated`, which fires on a single click on single-click platforms. - Fold the reported-user bolding pass into the fill loop. --- cockatrice/CMakeLists.txt | 2 ++ .../widgets/dialogs/dlg_my_reports.cpp | 3 +- .../src/interface/widgets/tabs/tab_alts.cpp | 5 +-- .../interface/widgets/tabs/tab_moderation.cpp | 12 +++++-- .../src/interface/widgets/tabs/tab_report.cpp | 7 ++-- .../widgets/utility/table_activation.cpp | 28 ++++++++++++++++ .../widgets/utility/table_activation.h | 32 +++++++++++++++++++ vcpkg | 2 +- 8 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 cockatrice/src/interface/widgets/utility/table_activation.cpp create mode 100644 cockatrice/src/interface/widgets/utility/table_activation.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 4707c3f32..bafee1b81 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -289,6 +289,8 @@ set(cockatrice_SOURCES src/interface/widgets/utility/get_text_with_max.cpp src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h + src/interface/widgets/utility/table_activation.cpp + src/interface/widgets/utility/table_activation.h src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp index 2a2758ba5..d5c4b62be 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -223,7 +223,8 @@ void DlgMyReports::onSelectionChanged() connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse); client->sendCommand(pend); - QString status = table->item(row, COL_STATUS)->text(); + // The cell text is the translated label; the raw status lives in UserRole. + const QString status = table->item(row, COL_STATUS)->data(Qt::UserRole).toString(); bool canComment = (status == "open" || status == "assigned"); commentButton->setEnabled(canComment); commentInput->setEnabled(canComment); diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index ec32bf60a..da08a0b56 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -1,6 +1,7 @@ #include "tab_alts.h" #include "../utility/epoch_format.h" +#include "../utility/table_activation.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" @@ -135,7 +136,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); - connect(altsTable, &QTableWidget::activated, this, [this](const QModelIndex &index) { + connectTableActivation(altsTable, this, [this](const QModelIndex &index) { if (!index.isValid()) { return; } @@ -393,7 +394,7 @@ void TabAlts::renderTable() altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && - alt.shared_count() > 1) { + alt.shared_count() > 2) { for (int col = 0; col < COL_ALTS_COUNT; ++col) { if (auto *item = altsTable->item(row, col)) { item->setForeground(dimColor); diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index 747eb7600..8568b0d2e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -1,6 +1,7 @@ #include "tab_moderation.h" #include "../utility/epoch_format.h" +#include "../utility/table_activation.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/response.pb.h" @@ -176,7 +177,7 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureTable(altsTable); - connect(altsTable, &QTableWidget::activated, this, [this](const QModelIndex &index) { + connectTableActivation(altsTable, this, [this](const QModelIndex &index) { if (!index.isValid()) { return; } @@ -456,7 +457,14 @@ void TabModeration::altsResponse(const Response &response) } const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); - cachedAltsCount = resp.alts_size(); + // The server always includes the frontier user as a depth-0 seed row. + int relatedCount = 0; + for (int i = 0; i < resp.alts_size(); ++i) { + if (!resp.alts(i).has_depth() || resp.alts(i).depth() > 0) { + ++relatedCount; + } + } + cachedAltsCount = relatedCount; applyAltsCount(); altsTable->setRowCount(resp.alts_size()); for (int i = 0; i < resp.alts_size(); ++i) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index ea2f47902..0dda1ed66 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -463,6 +463,9 @@ void TabReport::applyFilters() table->setSortingEnabled(false); table->setRowCount(filteredReports.size()); + QFont reportedFont = table->font(); + reportedFont.setBold(true); + for (int row = 0; row < filteredReports.size(); ++row) { const ServerInfo_Report &r = filteredReports[row]; @@ -476,11 +479,7 @@ void TabReport::applyFilters() table->setItem(row, COL_ROOM, new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : "")); - } - QFont reportedFont = table->font(); - reportedFont.setBold(true); - for (int row = 0; row < filteredReports.size(); ++row) { if (auto *reportedItem = table->item(row, COL_REPORTED)) { reportedItem->setFont(reportedFont); } diff --git a/cockatrice/src/interface/widgets/utility/table_activation.cpp b/cockatrice/src/interface/widgets/utility/table_activation.cpp new file mode 100644 index 000000000..5633b9f58 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.cpp @@ -0,0 +1,28 @@ +#include "table_activation.h" + +#include +#include +#include + +namespace +{ +void addActivationShortcut(QTableWidget *table, + QObject *context, + int key, + const std::function &handler) +{ + auto *shortcut = new QShortcut(QKeySequence(key), table); + // The table's viewport takes focus, so match children as well. + shortcut->setContext(Qt::WidgetWithChildrenShortcut); + QObject::connect(shortcut, &QShortcut::activated, context, [table, handler] { handler(table->currentIndex()); }); +} +} // namespace + +void connectTableActivation(QTableWidget *table, + QObject *context, + const std::function &handler) +{ + QObject::connect(table, &QTableWidget::doubleClicked, context, handler); + addActivationShortcut(table, context, Qt::Key_Return, handler); + addActivationShortcut(table, context, Qt::Key_Enter, handler); +} diff --git a/cockatrice/src/interface/widgets/utility/table_activation.h b/cockatrice/src/interface/widgets/utility/table_activation.h new file mode 100644 index 000000000..f5038bdb0 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.h @@ -0,0 +1,32 @@ +/** + * @file table_activation.h + * @brief Row activation for tables that must not navigate on a single click. + */ + +#ifndef TABLE_ACTIVATION_H +#define TABLE_ACTIVATION_H + +#include + +class QModelIndex; +class QObject; +class QTableWidget; + +/** + * @brief Call @p handler on double-click or Enter, never on a single click. + * + * QAbstractItemView::activated fires on a single click on platforms where + * SH_ItemView_ActivateItemOnSingleClick is set, so navigation actions must not + * use it. This connects doubleClicked and installs Return/Enter shortcuts that + * forward the current index. + * + * @param table The table whose rows activate. + * @param context Object that owns the connections; the handler is disconnected + * with it. + * @param handler Receives the activated row's index, which may be invalid. + */ +void connectTableActivation(QTableWidget *table, + QObject *context, + const std::function &handler); + +#endif // TABLE_ACTIVATION_H diff --git a/vcpkg b/vcpkg index 9eec002df..9e593bb18 160000 --- a/vcpkg +++ b/vcpkg @@ -1 +1 @@ -Subproject commit 9eec002df1cd109f8f104cd966a763daf9751489 +Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d From 7b8f57613a3a54d534380d7e8589b71dd83b3c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 05:29:12 +0100 Subject: [PATCH 2/8] Fix remaining review threads on the report queue dashboard - Read the raw report status from Qt::UserRole in DlgMyReports, so regular users can comment on their own open reports again. - Exclude the server's depth-0 seed row from the related-alt count. - Dim only IP matches shared by more than the pair, not every IP match. - Activate alts rows on double-click or Enter instead of `activated`, which fires on a single click on single-click platforms. - Fold the reported-user bolding pass into the fill loop. --- cockatrice/CMakeLists.txt | 2 ++ .../widgets/dialogs/dlg_my_reports.cpp | 3 +- .../src/interface/widgets/tabs/tab_alts.cpp | 5 +-- .../interface/widgets/tabs/tab_moderation.cpp | 12 +++++-- .../src/interface/widgets/tabs/tab_report.cpp | 7 ++-- .../widgets/utility/table_activation.cpp | 28 ++++++++++++++++ .../widgets/utility/table_activation.h | 32 +++++++++++++++++++ 7 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 cockatrice/src/interface/widgets/utility/table_activation.cpp create mode 100644 cockatrice/src/interface/widgets/utility/table_activation.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 4707c3f32..bafee1b81 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -289,6 +289,8 @@ set(cockatrice_SOURCES src/interface/widgets/utility/get_text_with_max.cpp src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h + src/interface/widgets/utility/table_activation.cpp + src/interface/widgets/utility/table_activation.h src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp index 2a2758ba5..d5c4b62be 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -223,7 +223,8 @@ void DlgMyReports::onSelectionChanged() connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse); client->sendCommand(pend); - QString status = table->item(row, COL_STATUS)->text(); + // The cell text is the translated label; the raw status lives in UserRole. + const QString status = table->item(row, COL_STATUS)->data(Qt::UserRole).toString(); bool canComment = (status == "open" || status == "assigned"); commentButton->setEnabled(canComment); commentInput->setEnabled(canComment); diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index ec32bf60a..da08a0b56 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -1,6 +1,7 @@ #include "tab_alts.h" #include "../utility/epoch_format.h" +#include "../utility/table_activation.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" @@ -135,7 +136,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); - connect(altsTable, &QTableWidget::activated, this, [this](const QModelIndex &index) { + connectTableActivation(altsTable, this, [this](const QModelIndex &index) { if (!index.isValid()) { return; } @@ -393,7 +394,7 @@ void TabAlts::renderTable() altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && - alt.shared_count() > 1) { + alt.shared_count() > 2) { for (int col = 0; col < COL_ALTS_COUNT; ++col) { if (auto *item = altsTable->item(row, col)) { item->setForeground(dimColor); diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index 747eb7600..8568b0d2e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -1,6 +1,7 @@ #include "tab_moderation.h" #include "../utility/epoch_format.h" +#include "../utility/table_activation.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/response.pb.h" @@ -176,7 +177,7 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureTable(altsTable); - connect(altsTable, &QTableWidget::activated, this, [this](const QModelIndex &index) { + connectTableActivation(altsTable, this, [this](const QModelIndex &index) { if (!index.isValid()) { return; } @@ -456,7 +457,14 @@ void TabModeration::altsResponse(const Response &response) } const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); - cachedAltsCount = resp.alts_size(); + // The server always includes the frontier user as a depth-0 seed row. + int relatedCount = 0; + for (int i = 0; i < resp.alts_size(); ++i) { + if (!resp.alts(i).has_depth() || resp.alts(i).depth() > 0) { + ++relatedCount; + } + } + cachedAltsCount = relatedCount; applyAltsCount(); altsTable->setRowCount(resp.alts_size()); for (int i = 0; i < resp.alts_size(); ++i) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index ea2f47902..0dda1ed66 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -463,6 +463,9 @@ void TabReport::applyFilters() table->setSortingEnabled(false); table->setRowCount(filteredReports.size()); + QFont reportedFont = table->font(); + reportedFont.setBold(true); + for (int row = 0; row < filteredReports.size(); ++row) { const ServerInfo_Report &r = filteredReports[row]; @@ -476,11 +479,7 @@ void TabReport::applyFilters() table->setItem(row, COL_ROOM, new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : "")); - } - QFont reportedFont = table->font(); - reportedFont.setBold(true); - for (int row = 0; row < filteredReports.size(); ++row) { if (auto *reportedItem = table->item(row, COL_REPORTED)) { reportedItem->setFont(reportedFont); } diff --git a/cockatrice/src/interface/widgets/utility/table_activation.cpp b/cockatrice/src/interface/widgets/utility/table_activation.cpp new file mode 100644 index 000000000..5633b9f58 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.cpp @@ -0,0 +1,28 @@ +#include "table_activation.h" + +#include +#include +#include + +namespace +{ +void addActivationShortcut(QTableWidget *table, + QObject *context, + int key, + const std::function &handler) +{ + auto *shortcut = new QShortcut(QKeySequence(key), table); + // The table's viewport takes focus, so match children as well. + shortcut->setContext(Qt::WidgetWithChildrenShortcut); + QObject::connect(shortcut, &QShortcut::activated, context, [table, handler] { handler(table->currentIndex()); }); +} +} // namespace + +void connectTableActivation(QTableWidget *table, + QObject *context, + const std::function &handler) +{ + QObject::connect(table, &QTableWidget::doubleClicked, context, handler); + addActivationShortcut(table, context, Qt::Key_Return, handler); + addActivationShortcut(table, context, Qt::Key_Enter, handler); +} diff --git a/cockatrice/src/interface/widgets/utility/table_activation.h b/cockatrice/src/interface/widgets/utility/table_activation.h new file mode 100644 index 000000000..f5038bdb0 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.h @@ -0,0 +1,32 @@ +/** + * @file table_activation.h + * @brief Row activation for tables that must not navigate on a single click. + */ + +#ifndef TABLE_ACTIVATION_H +#define TABLE_ACTIVATION_H + +#include + +class QModelIndex; +class QObject; +class QTableWidget; + +/** + * @brief Call @p handler on double-click or Enter, never on a single click. + * + * QAbstractItemView::activated fires on a single click on platforms where + * SH_ItemView_ActivateItemOnSingleClick is set, so navigation actions must not + * use it. This connects doubleClicked and installs Return/Enter shortcuts that + * forward the current index. + * + * @param table The table whose rows activate. + * @param context Object that owns the connections; the handler is disconnected + * with it. + * @param handler Receives the activated row's index, which may be invalid. + */ +void connectTableActivation(QTableWidget *table, + QObject *context, + const std::function &handler); + +#endif // TABLE_ACTIVATION_H From 803e1e9ec398cf7e037fff23c43f0f782a0b8e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 22:09:09 +0200 Subject: [PATCH 3/8] [Client] Alt badges, breadcrumb trail, investigate button AltTableDelegate paints palette-tinted match badges with paint-through selection, style warning/critical icons on nonzero counts, and font/elision handling. TabAlts gains a clickable breadcrumb trail, an Investigate-this-user button, and bold usernames. Async alts responses are guarded against stale investigations. --- cockatrice/CMakeLists.txt | 1 + .../widgets/tabs/alt_table_delegate.cpp | 132 ++++++++++++++++++ .../widgets/tabs/alt_table_delegate.h | 35 +++++ .../src/interface/widgets/tabs/tab_alts.cpp | 60 ++++++++ .../src/interface/widgets/tabs/tab_alts.h | 6 + 5 files changed, 234 insertions(+) create mode 100644 cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/alt_table_delegate.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bafee1b81..bfc9e040a 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -378,6 +378,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp + src/interface/widgets/tabs/alt_table_delegate.cpp src/interface/widgets/tabs/report_status_delegate.cpp src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp new file mode 100644 index 000000000..aa9ce907c --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp @@ -0,0 +1,132 @@ +#include "alt_table_delegate.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr qreal PILL_ALPHA = 0.30; +constexpr int PILL_PAD_X = 10; +constexpr int PILL_PAD_Y = 4; +constexpr int ICON_TEXT_GAP = 4; +} // namespace + +AltTableDelegate::AltTableDelegate(int viaColumn, int warnsColumn, int bansColumn, int sharedColumn, QObject *parent) + : QStyledItemDelegate(parent), viaColumn(viaColumn), warnsColumn(warnsColumn), bansColumn(bansColumn), + sharedColumn(sharedColumn) +{ +} + +void AltTableDelegate::setVpnThreshold(int threshold) +{ + vpnThreshold = threshold; +} + +void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const bool selected = (option.state & QStyle::State_Selected) != 0; + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + painter->setFont(opt.font); + QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); + const QString text = index.data(Qt::DisplayRole).toString(); + const QPalette::ColorRole emphasisRole = selected ? QPalette::HighlightedText : QPalette::WindowText; + + auto paintPill = [&](QPalette::ColorRole tintRole, QPalette::ColorRole textRole, const QIcon &icon, + const QString &pillText) { + const QRect pillRect = opt.rect.adjusted(PILL_PAD_X / 2, PILL_PAD_Y / 2, -PILL_PAD_X / 2, -PILL_PAD_Y / 2); + if (pillRect.width() <= 0 || pillRect.height() <= 0) { + QStyledItemDelegate::paint(painter, opt, index); + return; + } + if (!selected) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QColor pill = opt.palette.color(tintRole); + pill.setAlphaF(PILL_ALPHA); + painter->setPen(Qt::NoPen); + painter->setBrush(pill); + painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); + painter->restore(); + } + + QRect textRect = pillRect; + if (!icon.isNull()) { + const int iconSize = qMin(pillRect.height(), pillRect.width()); + if (iconSize > 0) { + const QRect iconRect(pillRect.left() + ICON_TEXT_GAP, pillRect.top(), iconSize, iconSize); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + ICON_TEXT_GAP); + } + } + const QString elided = opt.fontMetrics.elidedText(pillText, opt.textElideMode, textRect.width()); + style->drawItemText(painter, textRect, Qt::AlignCenter, opt.palette, true, elided, textRole); + }; + + if (index.column() == viaColumn) { + bool sharedOk = false; + const unsigned shared = index.sibling(index.row(), sharedColumn).data(Qt::DisplayRole).toUInt(&sharedOk); + const bool dimmed = sharedOk && text.startsWith(QStringLiteral("ip:")) && shared > 1; + const bool vpn = + sharedOk && text.startsWith(QStringLiteral("ip:")) && shared > static_cast(vpnThreshold); + const QPalette::ColorRole textRole = (dimmed && !selected) ? QPalette::PlaceholderText : emphasisRole; + if (text == QStringLiteral("email")) { + paintPill(QPalette::Link, emphasisRole, QIcon(), text); + return; + } + if (text.startsWith(QStringLiteral("clientid:"))) { + paintPill(QPalette::Highlight, emphasisRole, QIcon(), text); + return; + } + if (text.startsWith(QStringLiteral("ip:"))) { + if (vpn) { + paintPill(QPalette::Highlight, textRole, style->standardIcon(QStyle::SP_MessageBoxWarning), text); + return; + } + paintPill(QPalette::PlaceholderText, textRole, QIcon(), text); + return; + } + QStyledItemDelegate::paint(painter, opt, index); + return; + } + + if (index.column() == warnsColumn || index.column() == bansColumn) { + bool countOk = false; + const int count = text.toInt(&countOk); + if (countOk && count > 0 && !selected) { + const QIcon icon = style->standardIcon(index.column() == bansColumn ? QStyle::SP_MessageBoxCritical + : QStyle::SP_MessageBoxWarning); + const int iconSize = qMax(0, opt.rect.height() - 2 * ICON_TEXT_GAP); + QRect textRect = opt.rect; + if (!icon.isNull() && iconSize > 0) { + const QRect iconRect(opt.rect.left() + ICON_TEXT_GAP, opt.rect.top() + ICON_TEXT_GAP, iconSize, + iconSize); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + ICON_TEXT_GAP); + } + const QString elided = opt.fontMetrics.elidedText(text, opt.textElideMode, textRect.width()); + style->drawItemText(painter, textRect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, elided, + emphasisRole); + return; + } + } + + QStyledItemDelegate::paint(painter, opt, index); +} + +QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const QSize hint = QStyledItemDelegate::sizeHint(option, index); + if (index.column() == viaColumn) { + return hint + QSize(PILL_PAD_X + 2 * ICON_TEXT_GAP, PILL_PAD_Y); + } + if (index.column() == warnsColumn || index.column() == bansColumn) { + return hint + QSize(2 * ICON_TEXT_GAP, 0); + } + return hint; +} diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h new file mode 100644 index 000000000..7d7af2928 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h @@ -0,0 +1,35 @@ +#ifndef ALT_TABLE_DELEGATE_H +#define ALT_TABLE_DELEGATE_H + +#include +#include + +/** + * Badge + icon painting for the User Alts table. The match-evidence column + * renders as a palette-tinted pill per match type (email / client id / IP), + * with the style's warning icon once an IP is shared by more than the VPN + * threshold. Warn/ban counts gain the style's warning/critical icons when + * nonzero. Selected rows and unknown content fall back to default painting. + */ +class AltTableDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit AltTableDelegate(int viaColumn, + int warnsColumn, + int bansColumn, + int sharedColumn, + QObject *parent = nullptr); + void setVpnThreshold(int threshold); + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + int viaColumn; + int warnsColumn; + int bansColumn; + int sharedColumn; + int vpnThreshold = 10; +}; + +#endif // ALT_TABLE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index da08a0b56..3d38a26ff 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -3,6 +3,7 @@ #include "../utility/epoch_format.h" #include "../utility/table_activation.h" #include "abstract_client.h" +#include "alt_table_delegate.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" @@ -102,6 +103,15 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q connect(backButton, &QPushButton::clicked, this, &TabAlts::goBack); statusLabel = new QLabel; + trailLabel = new QLabel; + trailLabel->setTextFormat(Qt::RichText); + trailLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); + trailLabel->setOpenExternalLinks(false); + connect(trailLabel, &QLabel::linkActivated, this, [this](const QString &userName) { + if (!userName.isEmpty() && userName != currentUser) { + investigate(userName); + } + }); auto *topBar = new QHBoxLayout; topBar->addWidget(backButton); @@ -136,6 +146,12 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); + altsDelegate = new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, altsTable); + altsDelegate->setVpnThreshold(vpnSpin->value()); + connect(vpnSpin, &QSpinBox::valueChanged, altsDelegate, &AltTableDelegate::setVpnThreshold); + altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate); connectTableActivation(altsTable, this, [this](const QModelIndex &index) { if (!index.isValid()) { return; @@ -174,6 +190,10 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q detailsLayout->addLayout(detailGrid); detailsLayout->addWidget(warnsTable, 1); detailsLayout->addWidget(bansTable, 1); + investigateSelectedButton = new QPushButton; + investigateSelectedButton->setEnabled(false); + connect(investigateSelectedButton, &QPushButton::clicked, this, &TabAlts::investigateSelected); + detailsLayout->addWidget(investigateSelectedButton); auto *contentSplitter = new QSplitter(Qt::Horizontal); contentSplitter->addWidget(altsTable); @@ -186,6 +206,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q mainLayout->addLayout(topBar); mainLayout->addLayout(filterBar); mainLayout->addWidget(statusLabel); + mainLayout->addWidget(trailLabel); mainLayout->addWidget(contentSplitter, 1); retranslateUi(); @@ -225,6 +246,8 @@ void TabAlts::retranslateUi() detailNameLabel->setText(tr("Name:")); detailCountsLabel->setText(tr("Counts:")); detailNotesLabel->setText(tr("Admin notes:")); + investigateSelectedButton->setText(tr("Investigate this user")); + investigateSelectedButton->setToolTip(tr("Open the selected account as a new investigation")); if (!lastAlts.isEmpty()) { renderTable(); @@ -262,16 +285,41 @@ void TabAlts::loadUser(const QString &userName) { currentUser = userName; searchEdit->setText(userName); + updateTrail(); investigateUser(); } +void TabAlts::updateTrail() +{ + QStringList trail = backStack; + trail << currentUser; + trail.removeAll(QString()); + QStringList links; + for (const QString &name : trail) { + const QString safeName = name.toHtmlEscaped(); + if (name == currentUser) { + links << QStringLiteral("") + safeName + QStringLiteral(""); + } else { + links << QStringLiteral("") + safeName + + QStringLiteral(""); + } + } + trailLabel->setText(links.join(QStringLiteral(" › "))); + trailLabel->setVisible(!links.isEmpty()); +} + void TabAlts::investigateUser() { const QString userName = searchEdit->text().simplified(); if (userName.isEmpty()) { return; } + if (userName != currentUser && !currentUser.isEmpty()) { + backStack << currentUser; + backButton->setEnabled(true); + } currentUser = userName; + updateTrail(); lastAlts.clear(); selectedUser.clear(); clearDetails(); @@ -396,6 +444,9 @@ void TabAlts::renderTable() if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && alt.shared_count() > 2) { for (int col = 0; col < COL_ALTS_COUNT; ++col) { + if (col == COL_ALTS_VIA) { + continue; + } if (auto *item = altsTable->item(row, col)) { item->setForeground(dimColor); } @@ -446,6 +497,13 @@ void TabAlts::onFilterChanged() renderTable(); } +void TabAlts::investigateSelected() +{ + if (!selectedUser.isEmpty() && selectedUser != currentUser) { + investigate(selectedUser); + } +} + void TabAlts::onAltSelected() { const auto selected = altsTable->selectedItems(); @@ -469,6 +527,7 @@ void TabAlts::requestDetails(const QString &userName) detailNotesEdit->clear(); warnsTable->setRowCount(0); bansTable->setRowCount(0); + investigateSelectedButton->setEnabled(userName != currentUser); Command_ReportUserInfo infoCmd; infoCmd.set_user_name(userName.toStdString()); @@ -508,6 +567,7 @@ void TabAlts::clearDetails() detailNotesEdit->clear(); warnsTable->setRowCount(0); bansTable->setRowCount(0); + investigateSelectedButton->setEnabled(false); } void TabAlts::userDetailsResponse(const Response &response) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 8c0086f25..4ba355447 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -10,6 +10,7 @@ #include class AbstractClient; +class AltTableDelegate; class QGroupBox; class QLabel; class QLineEdit; @@ -41,6 +42,7 @@ public: private slots: void investigateUser(); void goBack(); + void investigateSelected(); void altsResponse(const Response &response); void onFilterChanged(); void onAltSelected(); @@ -50,6 +52,7 @@ private slots: private: void loadUser(const QString &userName); + void updateTrail(); void requestAlts(const QString &userName, int depth); void requestDetails(const QString &userName); void clearDetails(); @@ -67,8 +70,11 @@ private: QSpinBox *depthSpin; QPushButton *investigateButton; QPushButton *backButton; + QPushButton *investigateSelectedButton; + QLabel *trailLabel; QLabel *statusLabel; QTableWidget *altsTable; + AltTableDelegate *altsDelegate; QPushButton *emailChip; QPushButton *clientIdChip; From 7495703c02815ba792538769c04a7bd5f23caa4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 12:21:16 +0100 Subject: [PATCH 4/8] [Client] Fix alt badge and trail review findings - Drop the dead VPN warning branch: rowVisible() already hides IP rows above the threshold, so setVpnThreshold(), its member and the spin-box connect never took effect. - Dim IP rows at shared_count > 2 instead of > 1: the count already includes the investigated account, so every IP row was dimmed. - Truncate backStack when a breadcrumb is clicked instead of pushing the current user again, so the trail keeps a single path and Back works. - Word-wrap the trail label so a long chain cannot widen the tab. - Extract the status/match pill painting into widgets/utility/pill_painter and use it from both delegates instead of duplicating it. --- cockatrice/CMakeLists.txt | 2 + .../widgets/tabs/alt_table_delegate.cpp | 71 +++++-------------- .../widgets/tabs/alt_table_delegate.h | 10 ++- .../widgets/tabs/report_status_delegate.cpp | 35 ++------- .../src/interface/widgets/tabs/tab_alts.cpp | 23 +++++- .../src/interface/widgets/tabs/tab_alts.h | 1 + .../widgets/utility/pill_painter.cpp | 41 +++++++++++ .../interface/widgets/utility/pill_painter.h | 29 ++++++++ 8 files changed, 120 insertions(+), 92 deletions(-) create mode 100644 cockatrice/src/interface/widgets/utility/pill_painter.cpp create mode 100644 cockatrice/src/interface/widgets/utility/pill_painter.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bfc9e040a..fc593058a 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -287,6 +287,8 @@ set(cockatrice_SOURCES src/interface/widgets/utility/custom_line_edit.cpp src/interface/widgets/utility/epoch_format.h src/interface/widgets/utility/get_text_with_max.cpp + src/interface/widgets/utility/pill_painter.cpp + src/interface/widgets/utility/pill_painter.h src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h src/interface/widgets/utility/table_activation.cpp diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp index aa9ce907c..3cc412a7e 100644 --- a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp @@ -1,5 +1,7 @@ #include "alt_table_delegate.h" +#include "../utility/pill_painter.h" + #include #include #include @@ -9,9 +11,6 @@ namespace { -constexpr qreal PILL_ALPHA = 0.30; -constexpr int PILL_PAD_X = 10; -constexpr int PILL_PAD_Y = 4; constexpr int ICON_TEXT_GAP = 4; } // namespace @@ -21,11 +20,6 @@ AltTableDelegate::AltTableDelegate(int viaColumn, int warnsColumn, int bansColum { } -void AltTableDelegate::setVpnThreshold(int threshold) -{ - vpnThreshold = threshold; -} - void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { const bool selected = (option.state & QStyle::State_Selected) != 0; @@ -37,58 +31,27 @@ void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opti const QString text = index.data(Qt::DisplayRole).toString(); const QPalette::ColorRole emphasisRole = selected ? QPalette::HighlightedText : QPalette::WindowText; - auto paintPill = [&](QPalette::ColorRole tintRole, QPalette::ColorRole textRole, const QIcon &icon, - const QString &pillText) { - const QRect pillRect = opt.rect.adjusted(PILL_PAD_X / 2, PILL_PAD_Y / 2, -PILL_PAD_X / 2, -PILL_PAD_Y / 2); - if (pillRect.width() <= 0 || pillRect.height() <= 0) { - QStyledItemDelegate::paint(painter, opt, index); - return; - } - if (!selected) { - painter->save(); - painter->setRenderHint(QPainter::Antialiasing, true); - QColor pill = opt.palette.color(tintRole); - pill.setAlphaF(PILL_ALPHA); - painter->setPen(Qt::NoPen); - painter->setBrush(pill); - painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); - painter->restore(); - } - - QRect textRect = pillRect; - if (!icon.isNull()) { - const int iconSize = qMin(pillRect.height(), pillRect.width()); - if (iconSize > 0) { - const QRect iconRect(pillRect.left() + ICON_TEXT_GAP, pillRect.top(), iconSize, iconSize); - icon.paint(painter, iconRect); - textRect.setLeft(iconRect.right() + ICON_TEXT_GAP); - } - } - const QString elided = opt.fontMetrics.elidedText(pillText, opt.textElideMode, textRect.width()); - style->drawItemText(painter, textRect, Qt::AlignCenter, opt.palette, true, elided, textRole); - }; - if (index.column() == viaColumn) { bool sharedOk = false; const unsigned shared = index.sibling(index.row(), sharedColumn).data(Qt::DisplayRole).toUInt(&sharedOk); - const bool dimmed = sharedOk && text.startsWith(QStringLiteral("ip:")) && shared > 1; - const bool vpn = - sharedOk && text.startsWith(QStringLiteral("ip:")) && shared > static_cast(vpnThreshold); + const bool ipMatch = text.startsWith(QStringLiteral("ip:")); + // shared_count for an IP match already includes the investigated account, + // so anything above 2 means the address is shared beyond that pair. + const bool dimmed = sharedOk && ipMatch && shared > 2; const QPalette::ColorRole textRole = (dimmed && !selected) ? QPalette::PlaceholderText : emphasisRole; + + QPalette::ColorRole tintRole = QPalette::NoRole; if (text == QStringLiteral("email")) { - paintPill(QPalette::Link, emphasisRole, QIcon(), text); - return; + tintRole = QPalette::Link; + } else if (text.startsWith(QStringLiteral("clientid:"))) { + tintRole = QPalette::Highlight; + } else if (ipMatch) { + tintRole = QPalette::PlaceholderText; } - if (text.startsWith(QStringLiteral("clientid:"))) { - paintPill(QPalette::Highlight, emphasisRole, QIcon(), text); - return; - } - if (text.startsWith(QStringLiteral("ip:"))) { - if (vpn) { - paintPill(QPalette::Highlight, textRole, style->standardIcon(QStyle::SP_MessageBoxWarning), text); - return; + if (tintRole != QPalette::NoRole) { + if (!pill_painter::paint(painter, opt, tintRole, textRole, text)) { + QStyledItemDelegate::paint(painter, opt, index); } - paintPill(QPalette::PlaceholderText, textRole, QIcon(), text); return; } QStyledItemDelegate::paint(painter, opt, index); @@ -123,7 +86,7 @@ QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QMode { const QSize hint = QStyledItemDelegate::sizeHint(option, index); if (index.column() == viaColumn) { - return hint + QSize(PILL_PAD_X + 2 * ICON_TEXT_GAP, PILL_PAD_Y); + return hint + QSize(pill_painter::PAD_X + 2 * ICON_TEXT_GAP, pill_painter::PAD_Y); } if (index.column() == warnsColumn || index.column() == bansColumn) { return hint + QSize(2 * ICON_TEXT_GAP, 0); diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h index 7d7af2928..73386b00d 100644 --- a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h @@ -6,10 +6,10 @@ /** * Badge + icon painting for the User Alts table. The match-evidence column - * renders as a palette-tinted pill per match type (email / client id / IP), - * with the style's warning icon once an IP is shared by more than the VPN - * threshold. Warn/ban counts gain the style's warning/critical icons when - * nonzero. Selected rows and unknown content fall back to default painting. + * renders as a palette-tinted pill per match type (email / client id / IP), and + * an IP shared by more than one other account is dimmed. Warn/ban counts gain + * the style's warning/critical icons when nonzero. Selected rows and unknown + * content fall back to default painting. */ class AltTableDelegate : public QStyledItemDelegate { @@ -20,7 +20,6 @@ public: int bansColumn, int sharedColumn, QObject *parent = nullptr); - void setVpnThreshold(int threshold); void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; @@ -29,7 +28,6 @@ private: int warnsColumn; int bansColumn; int sharedColumn; - int vpnThreshold = 10; }; #endif // ALT_TABLE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp index fe59702c4..62e427e0e 100644 --- a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp @@ -1,18 +1,13 @@ #include "report_status_delegate.h" +#include "../utility/pill_painter.h" + #include #include #include #include #include -namespace -{ -constexpr qreal PILL_ALPHA = 0.30; -constexpr int PILL_PAD_X = 10; -constexpr int PILL_PAD_Y = 4; -} // namespace - ReportStatusDelegate::ReportStatusDelegate(QObject *parent) : QStyledItemDelegate(parent) { } @@ -46,32 +41,14 @@ void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem & return; } - const QRect pillRect = opt.rect.adjusted(PILL_PAD_X / 2, PILL_PAD_Y / 2, -PILL_PAD_X / 2, -PILL_PAD_Y / 2); - if (pillRect.width() <= 0 || pillRect.height() <= 0) { - QStyledItemDelegate::paint(painter, opt, index); - return; - } - - if (!selected) { - painter->save(); - painter->setRenderHint(QPainter::Antialiasing, true); - QColor pill = opt.palette.color(tintRole); - pill.setAlphaF(PILL_ALPHA); - painter->setPen(Qt::NoPen); - painter->setBrush(pill); - painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); - painter->restore(); - } - - const QString elided = - opt.fontMetrics.elidedText(opt.text.isEmpty() ? status : opt.text, opt.textElideMode, pillRect.width()); const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::WindowText; - QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); - style->drawItemText(painter, pillRect, Qt::AlignCenter, opt.palette, true, elided, textRole); + if (!pill_painter::paint(painter, opt, tintRole, textRole, opt.text.isEmpty() ? status : opt.text)) { + QStyledItemDelegate::paint(painter, opt, index); + } } QSize ReportStatusDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { const QSize hint = QStyledItemDelegate::sizeHint(option, index); - return hint + QSize(PILL_PAD_X, PILL_PAD_Y); + return hint + QSize(pill_painter::PAD_X, pill_painter::PAD_Y); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index 3d38a26ff..fb842122d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -107,9 +107,12 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q trailLabel->setTextFormat(Qt::RichText); trailLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); trailLabel->setOpenExternalLinks(false); + // The trail grows with every hop, so let it wrap instead of forcing the tab + // wider than the window. + trailLabel->setWordWrap(true); connect(trailLabel, &QLabel::linkActivated, this, [this](const QString &userName) { if (!userName.isEmpty() && userName != currentUser) { - investigate(userName); + goToTrailEntry(userName); } }); @@ -147,8 +150,6 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); altsDelegate = new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, altsTable); - altsDelegate->setVpnThreshold(vpnSpin->value()); - connect(vpnSpin, &QSpinBox::valueChanged, altsDelegate, &AltTableDelegate::setVpnThreshold); altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate); altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate); altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate); @@ -271,6 +272,20 @@ void TabAlts::investigate(const QString &userName) loadUser(userName); } +void TabAlts::goToTrailEntry(const QString &userName) +{ + const int index = backStack.indexOf(userName); + if (index < 0) { + investigate(userName); + return; + } + // Clicking an earlier crumb truncates the history there instead of pushing + // another entry, so the trail keeps a single path and Back still works. + backStack.erase(backStack.begin() + index, backStack.end()); + backButton->setEnabled(!backStack.isEmpty()); + loadUser(userName); +} + void TabAlts::goBack() { if (backStack.isEmpty()) { @@ -441,6 +456,8 @@ void TabAlts::renderTable() altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + // shared_count already includes the investigated account, so anything + // above 2 means the address is shared beyond that pair. if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && alt.shared_count() > 2) { for (int col = 0; col < COL_ALTS_COUNT; ++col) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 4ba355447..50aabf8f5 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -52,6 +52,7 @@ private slots: private: void loadUser(const QString &userName); + void goToTrailEntry(const QString &userName); void updateTrail(); void requestAlts(const QString &userName, int depth); void requestDetails(const QString &userName); diff --git a/cockatrice/src/interface/widgets/utility/pill_painter.cpp b/cockatrice/src/interface/widgets/utility/pill_painter.cpp new file mode 100644 index 000000000..6f317b951 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/pill_painter.cpp @@ -0,0 +1,41 @@ +#include "pill_painter.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr qreal PILL_ALPHA = 0.30; +} // namespace + +bool pill_painter::paint(QPainter *painter, + const QStyleOptionViewItem &option, + QPalette::ColorRole tintRole, + QPalette::ColorRole textRole, + const QString &text) +{ + const QRect pillRect = option.rect.adjusted(PAD_X / 2, PAD_Y / 2, -PAD_X / 2, -PAD_Y / 2); + if (pillRect.width() <= 0 || pillRect.height() <= 0) { + return false; + } + + if ((option.state & QStyle::State_Selected) == 0) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QColor pill = option.palette.color(tintRole); + pill.setAlphaF(PILL_ALPHA); + painter->setPen(Qt::NoPen); + painter->setBrush(pill); + painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); + painter->restore(); + } + + QStyle *style = option.widget != nullptr ? option.widget->style() : QApplication::style(); + const QString elided = option.fontMetrics.elidedText(text, option.textElideMode, pillRect.width()); + style->drawItemText(painter, pillRect, Qt::AlignCenter, option.palette, true, elided, textRole); + return true; +} diff --git a/cockatrice/src/interface/widgets/utility/pill_painter.h b/cockatrice/src/interface/widgets/utility/pill_painter.h new file mode 100644 index 000000000..72e3fe0b4 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/pill_painter.h @@ -0,0 +1,29 @@ +#ifndef PILL_PAINTER_H +#define PILL_PAINTER_H + +#include +#include + +class QPainter; +class QStyleOptionViewItem; + +namespace pill_painter +{ +constexpr int PAD_X = 10; +constexpr int PAD_Y = 4; + +/** + * Draws a translucent, palette-tinted rounded pill inside the option's rect and + * paints the text elided and centred on it, so badges follow the active theme. + * Returns false when the cell is too small to hold a pill, leaving the caller to + * fall back to default painting. + */ +bool paint(QPainter *painter, + const QStyleOptionViewItem &option, + QPalette::ColorRole tintRole, + QPalette::ColorRole textRole, + const QString &text); + +} // namespace pill_painter + +#endif // PILL_PAINTER_H From 2b7ddfd9682d9ed8c4b29e95d6bbf635e150a5bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 2 Nov 2026 10:13:31 +0100 Subject: [PATCH 5/8] [Client] Route investigate() through the trail helper --- cockatrice/src/interface/widgets/tabs/tab_alts.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index fb842122d..833584a39 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -265,6 +265,13 @@ void TabAlts::investigate(const QString &userName) if (userName.isEmpty() || userName == currentUser) { return; } + // Alt matches are symmetric, so revisiting an earlier account is a normal + // double-click. Truncate there instead of pushing it again, so the trail + // stays a single path. + if (backStack.contains(userName)) { + goToTrailEntry(userName); + return; + } if (!currentUser.isEmpty()) { backStack << currentUser; backButton->setEnabled(true); From d7b27861841777875762677f3bfb7ecefa168eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 22:09:09 +0200 Subject: [PATCH 6/8] [Client] Alt badges, breadcrumb trail, investigate button AltTableDelegate paints palette-tinted match badges with paint-through selection, style warning/critical icons on nonzero counts, and font/elision handling. TabAlts gains a clickable breadcrumb trail, an Investigate-this-user button, and bold usernames. Async alts responses are guarded against stale investigations. --- cockatrice/CMakeLists.txt | 1 + .../widgets/tabs/alt_table_delegate.cpp | 132 ++++++++++++++++++ .../widgets/tabs/alt_table_delegate.h | 35 +++++ .../src/interface/widgets/tabs/tab_alts.cpp | 60 ++++++++ .../src/interface/widgets/tabs/tab_alts.h | 6 + 5 files changed, 234 insertions(+) create mode 100644 cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/alt_table_delegate.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bafee1b81..bfc9e040a 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -378,6 +378,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp + src/interface/widgets/tabs/alt_table_delegate.cpp src/interface/widgets/tabs/report_status_delegate.cpp src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp new file mode 100644 index 000000000..aa9ce907c --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp @@ -0,0 +1,132 @@ +#include "alt_table_delegate.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr qreal PILL_ALPHA = 0.30; +constexpr int PILL_PAD_X = 10; +constexpr int PILL_PAD_Y = 4; +constexpr int ICON_TEXT_GAP = 4; +} // namespace + +AltTableDelegate::AltTableDelegate(int viaColumn, int warnsColumn, int bansColumn, int sharedColumn, QObject *parent) + : QStyledItemDelegate(parent), viaColumn(viaColumn), warnsColumn(warnsColumn), bansColumn(bansColumn), + sharedColumn(sharedColumn) +{ +} + +void AltTableDelegate::setVpnThreshold(int threshold) +{ + vpnThreshold = threshold; +} + +void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const bool selected = (option.state & QStyle::State_Selected) != 0; + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + painter->setFont(opt.font); + QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); + const QString text = index.data(Qt::DisplayRole).toString(); + const QPalette::ColorRole emphasisRole = selected ? QPalette::HighlightedText : QPalette::WindowText; + + auto paintPill = [&](QPalette::ColorRole tintRole, QPalette::ColorRole textRole, const QIcon &icon, + const QString &pillText) { + const QRect pillRect = opt.rect.adjusted(PILL_PAD_X / 2, PILL_PAD_Y / 2, -PILL_PAD_X / 2, -PILL_PAD_Y / 2); + if (pillRect.width() <= 0 || pillRect.height() <= 0) { + QStyledItemDelegate::paint(painter, opt, index); + return; + } + if (!selected) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QColor pill = opt.palette.color(tintRole); + pill.setAlphaF(PILL_ALPHA); + painter->setPen(Qt::NoPen); + painter->setBrush(pill); + painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); + painter->restore(); + } + + QRect textRect = pillRect; + if (!icon.isNull()) { + const int iconSize = qMin(pillRect.height(), pillRect.width()); + if (iconSize > 0) { + const QRect iconRect(pillRect.left() + ICON_TEXT_GAP, pillRect.top(), iconSize, iconSize); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + ICON_TEXT_GAP); + } + } + const QString elided = opt.fontMetrics.elidedText(pillText, opt.textElideMode, textRect.width()); + style->drawItemText(painter, textRect, Qt::AlignCenter, opt.palette, true, elided, textRole); + }; + + if (index.column() == viaColumn) { + bool sharedOk = false; + const unsigned shared = index.sibling(index.row(), sharedColumn).data(Qt::DisplayRole).toUInt(&sharedOk); + const bool dimmed = sharedOk && text.startsWith(QStringLiteral("ip:")) && shared > 1; + const bool vpn = + sharedOk && text.startsWith(QStringLiteral("ip:")) && shared > static_cast(vpnThreshold); + const QPalette::ColorRole textRole = (dimmed && !selected) ? QPalette::PlaceholderText : emphasisRole; + if (text == QStringLiteral("email")) { + paintPill(QPalette::Link, emphasisRole, QIcon(), text); + return; + } + if (text.startsWith(QStringLiteral("clientid:"))) { + paintPill(QPalette::Highlight, emphasisRole, QIcon(), text); + return; + } + if (text.startsWith(QStringLiteral("ip:"))) { + if (vpn) { + paintPill(QPalette::Highlight, textRole, style->standardIcon(QStyle::SP_MessageBoxWarning), text); + return; + } + paintPill(QPalette::PlaceholderText, textRole, QIcon(), text); + return; + } + QStyledItemDelegate::paint(painter, opt, index); + return; + } + + if (index.column() == warnsColumn || index.column() == bansColumn) { + bool countOk = false; + const int count = text.toInt(&countOk); + if (countOk && count > 0 && !selected) { + const QIcon icon = style->standardIcon(index.column() == bansColumn ? QStyle::SP_MessageBoxCritical + : QStyle::SP_MessageBoxWarning); + const int iconSize = qMax(0, opt.rect.height() - 2 * ICON_TEXT_GAP); + QRect textRect = opt.rect; + if (!icon.isNull() && iconSize > 0) { + const QRect iconRect(opt.rect.left() + ICON_TEXT_GAP, opt.rect.top() + ICON_TEXT_GAP, iconSize, + iconSize); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + ICON_TEXT_GAP); + } + const QString elided = opt.fontMetrics.elidedText(text, opt.textElideMode, textRect.width()); + style->drawItemText(painter, textRect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, elided, + emphasisRole); + return; + } + } + + QStyledItemDelegate::paint(painter, opt, index); +} + +QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const QSize hint = QStyledItemDelegate::sizeHint(option, index); + if (index.column() == viaColumn) { + return hint + QSize(PILL_PAD_X + 2 * ICON_TEXT_GAP, PILL_PAD_Y); + } + if (index.column() == warnsColumn || index.column() == bansColumn) { + return hint + QSize(2 * ICON_TEXT_GAP, 0); + } + return hint; +} diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h new file mode 100644 index 000000000..7d7af2928 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h @@ -0,0 +1,35 @@ +#ifndef ALT_TABLE_DELEGATE_H +#define ALT_TABLE_DELEGATE_H + +#include +#include + +/** + * Badge + icon painting for the User Alts table. The match-evidence column + * renders as a palette-tinted pill per match type (email / client id / IP), + * with the style's warning icon once an IP is shared by more than the VPN + * threshold. Warn/ban counts gain the style's warning/critical icons when + * nonzero. Selected rows and unknown content fall back to default painting. + */ +class AltTableDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit AltTableDelegate(int viaColumn, + int warnsColumn, + int bansColumn, + int sharedColumn, + QObject *parent = nullptr); + void setVpnThreshold(int threshold); + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + int viaColumn; + int warnsColumn; + int bansColumn; + int sharedColumn; + int vpnThreshold = 10; +}; + +#endif // ALT_TABLE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index da08a0b56..3d38a26ff 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -3,6 +3,7 @@ #include "../utility/epoch_format.h" #include "../utility/table_activation.h" #include "abstract_client.h" +#include "alt_table_delegate.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" @@ -102,6 +103,15 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q connect(backButton, &QPushButton::clicked, this, &TabAlts::goBack); statusLabel = new QLabel; + trailLabel = new QLabel; + trailLabel->setTextFormat(Qt::RichText); + trailLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); + trailLabel->setOpenExternalLinks(false); + connect(trailLabel, &QLabel::linkActivated, this, [this](const QString &userName) { + if (!userName.isEmpty() && userName != currentUser) { + investigate(userName); + } + }); auto *topBar = new QHBoxLayout; topBar->addWidget(backButton); @@ -136,6 +146,12 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); + altsDelegate = new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, altsTable); + altsDelegate->setVpnThreshold(vpnSpin->value()); + connect(vpnSpin, &QSpinBox::valueChanged, altsDelegate, &AltTableDelegate::setVpnThreshold); + altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate); connectTableActivation(altsTable, this, [this](const QModelIndex &index) { if (!index.isValid()) { return; @@ -174,6 +190,10 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q detailsLayout->addLayout(detailGrid); detailsLayout->addWidget(warnsTable, 1); detailsLayout->addWidget(bansTable, 1); + investigateSelectedButton = new QPushButton; + investigateSelectedButton->setEnabled(false); + connect(investigateSelectedButton, &QPushButton::clicked, this, &TabAlts::investigateSelected); + detailsLayout->addWidget(investigateSelectedButton); auto *contentSplitter = new QSplitter(Qt::Horizontal); contentSplitter->addWidget(altsTable); @@ -186,6 +206,7 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q mainLayout->addLayout(topBar); mainLayout->addLayout(filterBar); mainLayout->addWidget(statusLabel); + mainLayout->addWidget(trailLabel); mainLayout->addWidget(contentSplitter, 1); retranslateUi(); @@ -225,6 +246,8 @@ void TabAlts::retranslateUi() detailNameLabel->setText(tr("Name:")); detailCountsLabel->setText(tr("Counts:")); detailNotesLabel->setText(tr("Admin notes:")); + investigateSelectedButton->setText(tr("Investigate this user")); + investigateSelectedButton->setToolTip(tr("Open the selected account as a new investigation")); if (!lastAlts.isEmpty()) { renderTable(); @@ -262,16 +285,41 @@ void TabAlts::loadUser(const QString &userName) { currentUser = userName; searchEdit->setText(userName); + updateTrail(); investigateUser(); } +void TabAlts::updateTrail() +{ + QStringList trail = backStack; + trail << currentUser; + trail.removeAll(QString()); + QStringList links; + for (const QString &name : trail) { + const QString safeName = name.toHtmlEscaped(); + if (name == currentUser) { + links << QStringLiteral("") + safeName + QStringLiteral(""); + } else { + links << QStringLiteral("") + safeName + + QStringLiteral(""); + } + } + trailLabel->setText(links.join(QStringLiteral(" › "))); + trailLabel->setVisible(!links.isEmpty()); +} + void TabAlts::investigateUser() { const QString userName = searchEdit->text().simplified(); if (userName.isEmpty()) { return; } + if (userName != currentUser && !currentUser.isEmpty()) { + backStack << currentUser; + backButton->setEnabled(true); + } currentUser = userName; + updateTrail(); lastAlts.clear(); selectedUser.clear(); clearDetails(); @@ -396,6 +444,9 @@ void TabAlts::renderTable() if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && alt.shared_count() > 2) { for (int col = 0; col < COL_ALTS_COUNT; ++col) { + if (col == COL_ALTS_VIA) { + continue; + } if (auto *item = altsTable->item(row, col)) { item->setForeground(dimColor); } @@ -446,6 +497,13 @@ void TabAlts::onFilterChanged() renderTable(); } +void TabAlts::investigateSelected() +{ + if (!selectedUser.isEmpty() && selectedUser != currentUser) { + investigate(selectedUser); + } +} + void TabAlts::onAltSelected() { const auto selected = altsTable->selectedItems(); @@ -469,6 +527,7 @@ void TabAlts::requestDetails(const QString &userName) detailNotesEdit->clear(); warnsTable->setRowCount(0); bansTable->setRowCount(0); + investigateSelectedButton->setEnabled(userName != currentUser); Command_ReportUserInfo infoCmd; infoCmd.set_user_name(userName.toStdString()); @@ -508,6 +567,7 @@ void TabAlts::clearDetails() detailNotesEdit->clear(); warnsTable->setRowCount(0); bansTable->setRowCount(0); + investigateSelectedButton->setEnabled(false); } void TabAlts::userDetailsResponse(const Response &response) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 8c0086f25..4ba355447 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -10,6 +10,7 @@ #include class AbstractClient; +class AltTableDelegate; class QGroupBox; class QLabel; class QLineEdit; @@ -41,6 +42,7 @@ public: private slots: void investigateUser(); void goBack(); + void investigateSelected(); void altsResponse(const Response &response); void onFilterChanged(); void onAltSelected(); @@ -50,6 +52,7 @@ private slots: private: void loadUser(const QString &userName); + void updateTrail(); void requestAlts(const QString &userName, int depth); void requestDetails(const QString &userName); void clearDetails(); @@ -67,8 +70,11 @@ private: QSpinBox *depthSpin; QPushButton *investigateButton; QPushButton *backButton; + QPushButton *investigateSelectedButton; + QLabel *trailLabel; QLabel *statusLabel; QTableWidget *altsTable; + AltTableDelegate *altsDelegate; QPushButton *emailChip; QPushButton *clientIdChip; From d09f4c5356b94174754b4e79522b676564f0eb16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 1 Nov 2026 12:21:16 +0100 Subject: [PATCH 7/8] [Client] Fix alt badge and trail review findings - Drop the dead VPN warning branch: rowVisible() already hides IP rows above the threshold, so setVpnThreshold(), its member and the spin-box connect never took effect. - Dim IP rows at shared_count > 2 instead of > 1: the count already includes the investigated account, so every IP row was dimmed. - Truncate backStack when a breadcrumb is clicked instead of pushing the current user again, so the trail keeps a single path and Back works. - Word-wrap the trail label so a long chain cannot widen the tab. - Extract the status/match pill painting into widgets/utility/pill_painter and use it from both delegates instead of duplicating it. --- cockatrice/CMakeLists.txt | 2 + .../widgets/tabs/alt_table_delegate.cpp | 71 +++++-------------- .../widgets/tabs/alt_table_delegate.h | 10 ++- .../widgets/tabs/report_status_delegate.cpp | 35 ++------- .../src/interface/widgets/tabs/tab_alts.cpp | 23 +++++- .../src/interface/widgets/tabs/tab_alts.h | 1 + .../widgets/utility/pill_painter.cpp | 41 +++++++++++ .../interface/widgets/utility/pill_painter.h | 29 ++++++++ 8 files changed, 120 insertions(+), 92 deletions(-) create mode 100644 cockatrice/src/interface/widgets/utility/pill_painter.cpp create mode 100644 cockatrice/src/interface/widgets/utility/pill_painter.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bfc9e040a..fc593058a 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -287,6 +287,8 @@ set(cockatrice_SOURCES src/interface/widgets/utility/custom_line_edit.cpp src/interface/widgets/utility/epoch_format.h src/interface/widgets/utility/get_text_with_max.cpp + src/interface/widgets/utility/pill_painter.cpp + src/interface/widgets/utility/pill_painter.h src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h src/interface/widgets/utility/table_activation.cpp diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp index aa9ce907c..3cc412a7e 100644 --- a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp @@ -1,5 +1,7 @@ #include "alt_table_delegate.h" +#include "../utility/pill_painter.h" + #include #include #include @@ -9,9 +11,6 @@ namespace { -constexpr qreal PILL_ALPHA = 0.30; -constexpr int PILL_PAD_X = 10; -constexpr int PILL_PAD_Y = 4; constexpr int ICON_TEXT_GAP = 4; } // namespace @@ -21,11 +20,6 @@ AltTableDelegate::AltTableDelegate(int viaColumn, int warnsColumn, int bansColum { } -void AltTableDelegate::setVpnThreshold(int threshold) -{ - vpnThreshold = threshold; -} - void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { const bool selected = (option.state & QStyle::State_Selected) != 0; @@ -37,58 +31,27 @@ void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &opti const QString text = index.data(Qt::DisplayRole).toString(); const QPalette::ColorRole emphasisRole = selected ? QPalette::HighlightedText : QPalette::WindowText; - auto paintPill = [&](QPalette::ColorRole tintRole, QPalette::ColorRole textRole, const QIcon &icon, - const QString &pillText) { - const QRect pillRect = opt.rect.adjusted(PILL_PAD_X / 2, PILL_PAD_Y / 2, -PILL_PAD_X / 2, -PILL_PAD_Y / 2); - if (pillRect.width() <= 0 || pillRect.height() <= 0) { - QStyledItemDelegate::paint(painter, opt, index); - return; - } - if (!selected) { - painter->save(); - painter->setRenderHint(QPainter::Antialiasing, true); - QColor pill = opt.palette.color(tintRole); - pill.setAlphaF(PILL_ALPHA); - painter->setPen(Qt::NoPen); - painter->setBrush(pill); - painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); - painter->restore(); - } - - QRect textRect = pillRect; - if (!icon.isNull()) { - const int iconSize = qMin(pillRect.height(), pillRect.width()); - if (iconSize > 0) { - const QRect iconRect(pillRect.left() + ICON_TEXT_GAP, pillRect.top(), iconSize, iconSize); - icon.paint(painter, iconRect); - textRect.setLeft(iconRect.right() + ICON_TEXT_GAP); - } - } - const QString elided = opt.fontMetrics.elidedText(pillText, opt.textElideMode, textRect.width()); - style->drawItemText(painter, textRect, Qt::AlignCenter, opt.palette, true, elided, textRole); - }; - if (index.column() == viaColumn) { bool sharedOk = false; const unsigned shared = index.sibling(index.row(), sharedColumn).data(Qt::DisplayRole).toUInt(&sharedOk); - const bool dimmed = sharedOk && text.startsWith(QStringLiteral("ip:")) && shared > 1; - const bool vpn = - sharedOk && text.startsWith(QStringLiteral("ip:")) && shared > static_cast(vpnThreshold); + const bool ipMatch = text.startsWith(QStringLiteral("ip:")); + // shared_count for an IP match already includes the investigated account, + // so anything above 2 means the address is shared beyond that pair. + const bool dimmed = sharedOk && ipMatch && shared > 2; const QPalette::ColorRole textRole = (dimmed && !selected) ? QPalette::PlaceholderText : emphasisRole; + + QPalette::ColorRole tintRole = QPalette::NoRole; if (text == QStringLiteral("email")) { - paintPill(QPalette::Link, emphasisRole, QIcon(), text); - return; + tintRole = QPalette::Link; + } else if (text.startsWith(QStringLiteral("clientid:"))) { + tintRole = QPalette::Highlight; + } else if (ipMatch) { + tintRole = QPalette::PlaceholderText; } - if (text.startsWith(QStringLiteral("clientid:"))) { - paintPill(QPalette::Highlight, emphasisRole, QIcon(), text); - return; - } - if (text.startsWith(QStringLiteral("ip:"))) { - if (vpn) { - paintPill(QPalette::Highlight, textRole, style->standardIcon(QStyle::SP_MessageBoxWarning), text); - return; + if (tintRole != QPalette::NoRole) { + if (!pill_painter::paint(painter, opt, tintRole, textRole, text)) { + QStyledItemDelegate::paint(painter, opt, index); } - paintPill(QPalette::PlaceholderText, textRole, QIcon(), text); return; } QStyledItemDelegate::paint(painter, opt, index); @@ -123,7 +86,7 @@ QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QMode { const QSize hint = QStyledItemDelegate::sizeHint(option, index); if (index.column() == viaColumn) { - return hint + QSize(PILL_PAD_X + 2 * ICON_TEXT_GAP, PILL_PAD_Y); + return hint + QSize(pill_painter::PAD_X + 2 * ICON_TEXT_GAP, pill_painter::PAD_Y); } if (index.column() == warnsColumn || index.column() == bansColumn) { return hint + QSize(2 * ICON_TEXT_GAP, 0); diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h index 7d7af2928..73386b00d 100644 --- a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h @@ -6,10 +6,10 @@ /** * Badge + icon painting for the User Alts table. The match-evidence column - * renders as a palette-tinted pill per match type (email / client id / IP), - * with the style's warning icon once an IP is shared by more than the VPN - * threshold. Warn/ban counts gain the style's warning/critical icons when - * nonzero. Selected rows and unknown content fall back to default painting. + * renders as a palette-tinted pill per match type (email / client id / IP), and + * an IP shared by more than one other account is dimmed. Warn/ban counts gain + * the style's warning/critical icons when nonzero. Selected rows and unknown + * content fall back to default painting. */ class AltTableDelegate : public QStyledItemDelegate { @@ -20,7 +20,6 @@ public: int bansColumn, int sharedColumn, QObject *parent = nullptr); - void setVpnThreshold(int threshold); void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; @@ -29,7 +28,6 @@ private: int warnsColumn; int bansColumn; int sharedColumn; - int vpnThreshold = 10; }; #endif // ALT_TABLE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp index fe59702c4..62e427e0e 100644 --- a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp @@ -1,18 +1,13 @@ #include "report_status_delegate.h" +#include "../utility/pill_painter.h" + #include #include #include #include #include -namespace -{ -constexpr qreal PILL_ALPHA = 0.30; -constexpr int PILL_PAD_X = 10; -constexpr int PILL_PAD_Y = 4; -} // namespace - ReportStatusDelegate::ReportStatusDelegate(QObject *parent) : QStyledItemDelegate(parent) { } @@ -46,32 +41,14 @@ void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem & return; } - const QRect pillRect = opt.rect.adjusted(PILL_PAD_X / 2, PILL_PAD_Y / 2, -PILL_PAD_X / 2, -PILL_PAD_Y / 2); - if (pillRect.width() <= 0 || pillRect.height() <= 0) { - QStyledItemDelegate::paint(painter, opt, index); - return; - } - - if (!selected) { - painter->save(); - painter->setRenderHint(QPainter::Antialiasing, true); - QColor pill = opt.palette.color(tintRole); - pill.setAlphaF(PILL_ALPHA); - painter->setPen(Qt::NoPen); - painter->setBrush(pill); - painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); - painter->restore(); - } - - const QString elided = - opt.fontMetrics.elidedText(opt.text.isEmpty() ? status : opt.text, opt.textElideMode, pillRect.width()); const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::WindowText; - QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); - style->drawItemText(painter, pillRect, Qt::AlignCenter, opt.palette, true, elided, textRole); + if (!pill_painter::paint(painter, opt, tintRole, textRole, opt.text.isEmpty() ? status : opt.text)) { + QStyledItemDelegate::paint(painter, opt, index); + } } QSize ReportStatusDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { const QSize hint = QStyledItemDelegate::sizeHint(option, index); - return hint + QSize(PILL_PAD_X, PILL_PAD_Y); + return hint + QSize(pill_painter::PAD_X, pill_painter::PAD_Y); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index 3d38a26ff..fb842122d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -107,9 +107,12 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q trailLabel->setTextFormat(Qt::RichText); trailLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); trailLabel->setOpenExternalLinks(false); + // The trail grows with every hop, so let it wrap instead of forcing the tab + // wider than the window. + trailLabel->setWordWrap(true); connect(trailLabel, &QLabel::linkActivated, this, [this](const QString &userName) { if (!userName.isEmpty() && userName != currentUser) { - investigate(userName); + goToTrailEntry(userName); } }); @@ -147,8 +150,6 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureAltTable(altsTable); altsDelegate = new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, altsTable); - altsDelegate->setVpnThreshold(vpnSpin->value()); - connect(vpnSpin, &QSpinBox::valueChanged, altsDelegate, &AltTableDelegate::setVpnThreshold); altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate); altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate); altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate); @@ -271,6 +272,20 @@ void TabAlts::investigate(const QString &userName) loadUser(userName); } +void TabAlts::goToTrailEntry(const QString &userName) +{ + const int index = backStack.indexOf(userName); + if (index < 0) { + investigate(userName); + return; + } + // Clicking an earlier crumb truncates the history there instead of pushing + // another entry, so the trail keeps a single path and Back still works. + backStack.erase(backStack.begin() + index, backStack.end()); + backButton->setEnabled(!backStack.isEmpty()); + loadUser(userName); +} + void TabAlts::goBack() { if (backStack.isEmpty()) { @@ -441,6 +456,8 @@ void TabAlts::renderTable() altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + // shared_count already includes the investigated account, so anything + // above 2 means the address is shared beyond that pair. if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && alt.shared_count() > 2) { for (int col = 0; col < COL_ALTS_COUNT; ++col) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index 4ba355447..50aabf8f5 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -52,6 +52,7 @@ private slots: private: void loadUser(const QString &userName); + void goToTrailEntry(const QString &userName); void updateTrail(); void requestAlts(const QString &userName, int depth); void requestDetails(const QString &userName); diff --git a/cockatrice/src/interface/widgets/utility/pill_painter.cpp b/cockatrice/src/interface/widgets/utility/pill_painter.cpp new file mode 100644 index 000000000..6f317b951 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/pill_painter.cpp @@ -0,0 +1,41 @@ +#include "pill_painter.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr qreal PILL_ALPHA = 0.30; +} // namespace + +bool pill_painter::paint(QPainter *painter, + const QStyleOptionViewItem &option, + QPalette::ColorRole tintRole, + QPalette::ColorRole textRole, + const QString &text) +{ + const QRect pillRect = option.rect.adjusted(PAD_X / 2, PAD_Y / 2, -PAD_X / 2, -PAD_Y / 2); + if (pillRect.width() <= 0 || pillRect.height() <= 0) { + return false; + } + + if ((option.state & QStyle::State_Selected) == 0) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QColor pill = option.palette.color(tintRole); + pill.setAlphaF(PILL_ALPHA); + painter->setPen(Qt::NoPen); + painter->setBrush(pill); + painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); + painter->restore(); + } + + QStyle *style = option.widget != nullptr ? option.widget->style() : QApplication::style(); + const QString elided = option.fontMetrics.elidedText(text, option.textElideMode, pillRect.width()); + style->drawItemText(painter, pillRect, Qt::AlignCenter, option.palette, true, elided, textRole); + return true; +} diff --git a/cockatrice/src/interface/widgets/utility/pill_painter.h b/cockatrice/src/interface/widgets/utility/pill_painter.h new file mode 100644 index 000000000..72e3fe0b4 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/pill_painter.h @@ -0,0 +1,29 @@ +#ifndef PILL_PAINTER_H +#define PILL_PAINTER_H + +#include +#include + +class QPainter; +class QStyleOptionViewItem; + +namespace pill_painter +{ +constexpr int PAD_X = 10; +constexpr int PAD_Y = 4; + +/** + * Draws a translucent, palette-tinted rounded pill inside the option's rect and + * paints the text elided and centred on it, so badges follow the active theme. + * Returns false when the cell is too small to hold a pill, leaving the caller to + * fall back to default painting. + */ +bool paint(QPainter *painter, + const QStyleOptionViewItem &option, + QPalette::ColorRole tintRole, + QPalette::ColorRole textRole, + const QString &text); + +} // namespace pill_painter + +#endif // PILL_PAINTER_H From c836d8b10dab0008feb5c29cf2b6539529f9de52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 2 Nov 2026 10:13:31 +0100 Subject: [PATCH 8/8] [Client] Route investigate() through the trail helper --- cockatrice/src/interface/widgets/tabs/tab_alts.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index fb842122d..833584a39 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -265,6 +265,13 @@ void TabAlts::investigate(const QString &userName) if (userName.isEmpty() || userName == currentUser) { return; } + // Alt matches are symmetric, so revisiting an earlier account is a normal + // double-click. Truncate there instead of pushing it again, so the trail + // stays a single path. + if (backStack.contains(userName)) { + goToTrailEntry(userName); + return; + } if (!currentUser.isEmpty()) { backStack << currentUser; backButton->setEnabled(true);