diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index e3e88b70c..1924d86bf 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -15,6 +15,8 @@ set(cockatrice_SOURCES src/client/network/update/client/client_update_checker.cpp src/client/network/update/client/release_channel.cpp src/client/network/update/card_spoiler/spoiler_background_updater.cpp + src/client/latency_graph_widget.cpp + src/client/latency_status_widget.cpp src/client/lag_monitor.cpp src/client/sound_engine.cpp src/client/settings/cache_settings.cpp diff --git a/cockatrice/src/client/latency_graph_widget.cpp b/cockatrice/src/client/latency_graph_widget.cpp new file mode 100644 index 000000000..46d6ccdd3 --- /dev/null +++ b/cockatrice/src/client/latency_graph_widget.cpp @@ -0,0 +1,51 @@ +/** + * @file latency_graph_widget.cpp + * @ingroup Client + */ + +#include "latency_graph_widget.h" + +#include + +LatencyGraphWidget::LatencyGraphWidget(QWidget *parent) : QWidget(parent) +{ +} + +void LatencyGraphWidget::setSamples(const QList &samplesMs) +{ + samples = samplesMs; + update(); +} + +void LatencyGraphWidget::paintEvent(QPaintEvent * /* event */) +{ + if (samples.isEmpty()) { + return; + } + + QPainter painter(this); + + // Heights are relative to the window's own worst sample (floored at + // MinScaleMs) so the shape of the variance stays readable even when every + // value is small. + qint64 heightScaleMs = MinScaleMs; + for (int sample : samples) { + heightScaleMs = qMax(heightScaleMs, static_cast(sample)); + } + + const qreal widthPerBar = static_cast(width()) / samples.size(); + for (int i = 0; i < samples.size(); ++i) { + const qreal heightRatio = qBound(0.0, static_cast(samples.at(i)) / heightScaleMs, 1.0); + const qreal barHeight = heightRatio * height(); + + // Colors follow an absolute quality ramp: a steady good ping stays + // green no matter how uniform the window is. + const qreal colorRatio = qBound(0.0, static_cast(samples.at(i)) / ColorScaleMs, 1.0); + QColor color; + color.setHsv(qRound(120.0 * (1.0 - colorRatio)), 255, 255); + + const QRectF bar(static_cast(i) * widthPerBar + 1.0, static_cast(height()) - barHeight, + qMax(1.0, widthPerBar - 2.0), barHeight); + painter.fillRect(bar, color); + } +} diff --git a/cockatrice/src/client/latency_graph_widget.h b/cockatrice/src/client/latency_graph_widget.h new file mode 100644 index 000000000..4f6f38f8b --- /dev/null +++ b/cockatrice/src/client/latency_graph_widget.h @@ -0,0 +1,43 @@ +/** + * @file latency_graph_widget.h + * @ingroup Client + */ + +#ifndef LATENCY_GRAPH_WIDGET_H +#define LATENCY_GRAPH_WIDGET_H + +#include +#include + +/** + * @brief Bar graph of recent network round-trip samples. + * + * Draws one bar per sample, oldest on the left. Bar height is relative to the + * window's own scale so the shape of the variance stays readable, while bar + * color maps each sample onto an absolute quality ramp (green at rest through + * red at ColorScaleMs) so a steady good ping never looks alarming. Size + * agnostic: the status bar embeds a small instance while the latency detail + * popup shows a large one. + */ +class LatencyGraphWidget : public QWidget +{ + Q_OBJECT +public: + explicit LatencyGraphWidget(QWidget *parent = nullptr); + + /// Sample in milliseconds that maps to a fully red bar. + static constexpr qint64 ColorScaleMs = 500; + + void setSamples(const QList &samplesMs); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + /// Floor of the vertical scale in milliseconds. Keeps small windows readable. + static constexpr qint64 MinScaleMs = 100; + + QList samples; +}; + +#endif diff --git a/cockatrice/src/client/latency_status_widget.cpp b/cockatrice/src/client/latency_status_widget.cpp new file mode 100644 index 000000000..779c726eb --- /dev/null +++ b/cockatrice/src/client/latency_status_widget.cpp @@ -0,0 +1,111 @@ +/** + * @file latency_status_widget.cpp + * @ingroup Client + */ + +#include "latency_status_widget.h" + +#include "latency_graph_widget.h" + +#include +#include +#include +#include + +LatencyStatusWidget::LatencyStatusWidget(QWidget *parent) : QWidget(parent) +{ + pingLabel = new QLabel(this); + pingLabel->setAccessibleName(tr("Ping")); + + latencyGraph = new LatencyGraphWidget(this); + latencyGraph->setFixedSize(90, 14); + + auto *layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(4); + layout->addWidget(latencyGraph); + layout->addWidget(pingLabel); + + // Clicking anywhere in the area opens the detail view. + for (QObject *child : QList{pingLabel, latencyGraph}) { + child->installEventFilter(this); + } + setCursor(Qt::PointingHandCursor); + + hide(); +} + +void LatencyStatusWidget::updateData(const LatencyTracker::Stats &stats, const QList &samplesMs) +{ + latestSamples = samplesMs; + latencyGraph->setSamples(samplesMs); + if (popup && popup->isVisible() && detailGraph) { + detailGraph->setSamples(samplesMs); + } + + if (stats.sampleCount == 0) { + hide(); + return; + } + + const QString statsStr = statsText(stats); + + pingLabel->setText(tr("Ping: %1 ms").arg(stats.lastMs)); + pingLabel->setToolTip(statsStr); + pingLabel->setAccessibleDescription(statsStr); + if (popup && popup->isVisible() && detailLabel) { + detailLabel->setText(statsStr); + } + show(); +} + +bool LatencyStatusWidget::eventFilter(QObject *watched, QEvent *event) +{ + if ((watched == pingLabel || watched == latencyGraph) && event->type() == QEvent::MouseButtonPress) { + togglePopup(); + return true; + } + return QWidget::eventFilter(watched, event); +} + +void LatencyStatusWidget::togglePopup() +{ + if (!popup) { + popup = new QWidget(this, Qt::Popup | Qt::FramelessWindowHint); + auto *layout = new QVBoxLayout(popup); + layout->setContentsMargins(8, 8, 8, 8); + + detailLabel = new QLabel(popup); + detailLabel->setAccessibleName(tr("Connection latency details")); + detailLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + + detailGraph = new LatencyGraphWidget(popup); + detailGraph->setFixedSize(280, 80); + + layout->addWidget(detailLabel, 0, Qt::AlignLeft); + layout->addWidget(detailGraph, 0, Qt::AlignHCenter); + } + + if (popup->isVisible()) { + popup->hide(); + return; + } + + // Qt::Popup closes itself on any outside click, so just position and show. + if (latestSamples.isEmpty()) { + return; + } + detailGraph->setSamples(latestSamples); + detailLabel->setText(pingLabel->toolTip()); + popup->adjustSize(); + const QPoint anchor = mapToGlobal(QPoint(width() / 2, 0)); + popup->move(anchor.x() - popup->width() / 2, anchor.y() - popup->height() - 6); + popup->show(); +} + +QString LatencyStatusWidget::statsText(const LatencyTracker::Stats &stats) const +{ + return tr("Connection quality over the last %n sample(s):", "", stats.sampleCount) + "\n" + + tr("Last: %1 ms").arg(stats.lastMs) + "\n" + tr("Median: %1 ms").arg(stats.medianMs) + "\n" + + tr("95th percentile: %1 ms").arg(stats.p95Ms) + "\n" + tr("Maximum: %1 ms").arg(stats.maxMs); +} diff --git a/cockatrice/src/client/latency_status_widget.h b/cockatrice/src/client/latency_status_widget.h new file mode 100644 index 000000000..d9e1d130c --- /dev/null +++ b/cockatrice/src/client/latency_status_widget.h @@ -0,0 +1,50 @@ +/** + * @file latency_status_widget.h + * @ingroup Client + */ + +#ifndef LATENCY_STATUS_WIDGET_H +#define LATENCY_STATUS_WIDGET_H + +#include +#include +#include + +class QLabel; +class LatencyGraphWidget; + +/** + * @brief Status bar presentation of server round-trip health. + * + * Combines the textual "Ping" readout with a small LatencyGraphWidget + * sparkline of the rolling sample window. Clicking anywhere in the area opens + * a popup with a larger graph and the numeric statistics. It closes on any + * outside click. Stays hidden while disconnected or before any samples exist. + * Owns all latency display state so MainWindow only needs to forward one + * signal here. + */ +class LatencyStatusWidget : public QWidget +{ + Q_OBJECT +public: + explicit LatencyStatusWidget(QWidget *parent = nullptr); + +public slots: + void updateData(const LatencyTracker::Stats &stats, const QList &samplesMs); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void togglePopup(); + QString statsText(const LatencyTracker::Stats &stats) const; + + QLabel *pingLabel = nullptr; + LatencyGraphWidget *latencyGraph = nullptr; + QWidget *popup = nullptr; + LatencyGraphWidget *detailGraph = nullptr; + QLabel *detailLabel = nullptr; + QList latestSamples; +}; + +#endif diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index f1b26da9d..f96c139b3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -141,6 +141,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * connect(client, &AbstractClient::gameJoinedEventReceived, this, &TabSupervisor::gameJoined); connect(client, &AbstractClient::userMessageEventReceived, this, &TabSupervisor::processUserMessageEvent); connect(client, &AbstractClient::maxPingTime, this, &TabSupervisor::updatePingTime); + connect(client, &AbstractClient::pingStatsUpdated, this, &TabSupervisor::updateLatencyTooltip); connect(client, &AbstractClient::notifyUserEventReceived, this, &TabSupervisor::processNotifyUserEvent); // create tabs menu actions @@ -883,6 +884,23 @@ void TabSupervisor::updatePingTime(int value, int max) setTabIcon(indexOf(tabServer), QIcon(PingPixmapGenerator::generatePixmap(15, value, max))); } +void TabSupervisor::updateLatencyTooltip(const LatencyTracker::Stats &stats) +{ + if (!tabServer) { + return; + } + + if (stats.sampleCount == 0) { + setTabToolTip(indexOf(tabServer), QString()); + return; + } + + setTabToolTip(indexOf(tabServer), + tr("Connection quality over the last %n sample(s):", "", stats.sampleCount) + "\n" + + tr("Last: %1 ms").arg(stats.lastMs) + "\n" + tr("Median: %1 ms").arg(stats.medianMs) + "\n" + + tr("95th percentile: %1 ms").arg(stats.p95Ms) + "\n" + tr("Maximum: %1 ms").arg(stats.maxMs)); +} + void TabSupervisor::gameJoined(const Event_GameJoined &event) { QMap roomGameTypes; diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 32ed14504..b389bad3e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -24,6 +24,7 @@ #include #include #include +#include class TabCardArtRules; inline Q_LOGGING_CATEGORY(TabSupervisorLog, "tab_supervisor"); @@ -220,6 +221,7 @@ private slots: void updateCurrent(int index); void updatePingTime(int value, int max); + void updateLatencyTooltip(const LatencyTracker::Stats &stats); void gameJoined(const Event_GameJoined &event); void localGameJoined(const Event_GameJoined &event); void gameLeft(TabGame *tab); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 199a2d952..13c37473e 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -19,6 +19,7 @@ ***************************************************************************/ #include "window_main.h" +#include "../client/latency_status_widget.h" #include "../client/network/update/client/client_update_checker.h" #include "../client/network/update/client/release_channel.h" #include "../client/settings/cache_settings.h" @@ -56,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -535,6 +537,12 @@ MainWindow::MainWindow(QWidget *parent) [this](bool show) { statusBar()->setVisible(show); }); statusBar()->setVisible(SettingsCache::instance().userInterface().getShowStatusBar()); + latencyStatus = new LatencyStatusWidget(this); + statusBar()->addPermanentWidget(latencyStatus); + + connect(connectionController, &ConnectionController::pingStatsUpdated, latencyStatus, + &LatencyStatusWidget::updateData); + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &MainWindow::refreshShortcuts); refreshShortcuts(); diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 3c0cc6302..baacd3096 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -50,6 +50,8 @@ class GameReplay; class HandlePublicServers; class LocalClient; class LocalServer; +class QLabel; +class LatencyStatusWidget; class QThread; class RemoteClient; class ServerInfo_User; @@ -146,7 +148,8 @@ private: WndSets *wndSets; ConnectionController *connectionController; LocalServer *localServer; - LagMonitor lagMonitor; ///< watches the main thread for event loop stalls + LagMonitor lagMonitor; ///< watches the main thread for event loop stalls + LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph bool bHasActivated, askedForDbUpdater; QProcess *cardUpdateProcess; DlgViewLog *logviewDialog;