[Client] Graph connection latency history in the status bar

Add LatencyGraphWidget, a size-agnostic bar sparkline over the rolling
sample window: heights scale to the window's own range while colors map
onto an absolute quality ramp, so a steady good ping stays green. Embed
it in the new LatencyStatusWidget together with the textual ping
readout and feed both through ConnectionController's forwarded signals;
the whole area hides while disconnected or without samples.

Took 9 minutes

Took 14 seconds
This commit is contained in:
Lukas Brübach 2026-08-22 17:43:11 +02:00
parent be92a7fd67
commit b11fb3d7ca
7 changed files with 195 additions and 24 deletions

View file

@ -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

View file

@ -0,0 +1,52 @@
/**
* @file latency_graph_widget.cpp
* @ingroup Client
*/
#include "latency_graph_widget.h"
#include <QPainter>
LatencyGraphWidget::LatencyGraphWidget(QWidget *parent) : QWidget(parent)
{
}
void LatencyGraphWidget::setSamples(const QList<int> &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<qint64>(sample));
}
const qreal widthPerBar = static_cast<qreal>(width()) / samples.size();
for (int i = 0; i < samples.size(); ++i) {
const qreal heightRatio = qBound(0.0, static_cast<double>(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<double>(samples.at(i)) / ColorScaleMs, 1.0);
QColor color;
color.setHsv(qRound(120.0 * (1.0 - colorRatio)), 255, 255);
const QRectF bar(static_cast<qreal>(i) * widthPerBar + 1.0,
static_cast<qreal>(height()) - barHeight,
qMax(1.0, widthPerBar - 2.0), barHeight);
painter.fillRect(bar, color);
}
}

View file

@ -0,0 +1,43 @@
/**
* @file latency_graph_widget.h
* @ingroup Client
*/
#ifndef LATENCY_GRAPH_WIDGET_H
#define LATENCY_GRAPH_WIDGET_H
#include <QList>
#include <QWidget>
/**
* @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<int> &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<int> samples;
};
#endif

View file

@ -0,0 +1,50 @@
/**
* @file latency_status_widget.cpp
* @ingroup Client
*/
#include "latency_status_widget.h"
#include "latency_graph_widget.h"
#include <QHBoxLayout>
#include <QLabel>
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);
hide();
}
void LatencyStatusWidget::updateStats(int lastMs, int medianMs, int p95Ms, int maxMs, int sampleCount)
{
if (sampleCount == 0) {
hide();
return;
}
const QString stats = tr("Connection quality over the last %n sample(s):", "", sampleCount) + "\n" +
tr("Last: %1 ms").arg(lastMs) + "\n" + tr("Median: %1 ms").arg(medianMs) + "\n" +
tr("95th percentile: %1 ms").arg(p95Ms) + "\n" + tr("Maximum: %1 ms").arg(maxMs);
pingLabel->setText(tr("Ping: %1 ms").arg(lastMs));
pingLabel->setToolTip(stats);
pingLabel->setAccessibleDescription(stats);
show();
}
void LatencyStatusWidget::updateSamples(const QList<int> &samplesMs)
{
latencyGraph->setSamples(samplesMs);
}

View file

@ -0,0 +1,38 @@
/**
* @file latency_status_widget.h
* @ingroup Client
*/
#ifndef LATENCY_STATUS_WIDGET_H
#define LATENCY_STATUS_WIDGET_H
#include <QList>
#include <QWidget>
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. Stays hidden while disconnected or
* before any samples exist. Owns all latency display state so MainWindow only
* needs to forward two signals here.
*/
class LatencyStatusWidget : public QWidget
{
Q_OBJECT
public:
explicit LatencyStatusWidget(QWidget *parent = nullptr);
public slots:
void updateStats(int lastMs, int medianMs, int p95Ms, int maxMs, int sampleCount);
void updateSamples(const QList<int> &samplesMs);
private:
QLabel *pingLabel = nullptr;
LatencyGraphWidget *latencyGraph = nullptr;
};
#endif

View file

@ -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"
@ -145,23 +146,6 @@ void MainWindow::statusChanged(ClientStatus _status)
}
}
void MainWindow::updatePingDisplay(int lastMs, int medianMs, int p95Ms, int maxMs, int sampleCount)
{
if (!pingLabel || sampleCount == 0) {
if (pingLabel) {
pingLabel->hide();
}
return;
}
pingLabel->setText(tr("Ping: %1 ms").arg(lastMs));
pingLabel->setToolTip(tr("Connection quality over the last %n sample(s):", "", sampleCount) + "\n" +
tr("Last: %1 ms").arg(lastMs) + "\n" + tr("Median: %1 ms").arg(medianMs) + "\n" +
tr("95th percentile: %1 ms").arg(p95Ms) + "\n" + tr("Maximum: %1 ms").arg(maxMs));
pingLabel->setAccessibleDescription(pingLabel->toolTip());
pingLabel->show();
}
// Actions
void MainWindow::actConnect()
@ -553,11 +537,13 @@ MainWindow::MainWindow(QWidget *parent)
[this](bool show) { statusBar()->setVisible(show); });
statusBar()->setVisible(SettingsCache::instance().userInterface().getShowStatusBar());
pingLabel = new QLabel(this);
pingLabel->setAccessibleName(tr("Ping"));
pingLabel->hide();
statusBar()->addPermanentWidget(pingLabel);
connect(connectionController, &ConnectionController::pingStatsUpdated, this, &MainWindow::updatePingDisplay);
latencyStatus = new LatencyStatusWidget(this);
statusBar()->addPermanentWidget(latencyStatus);
connect(connectionController, &ConnectionController::pingStatsUpdated, latencyStatus,
&LatencyStatusWidget::updateStats);
connect(connectionController, &ConnectionController::pingSamplesUpdated, latencyStatus,
&LatencyStatusWidget::updateSamples);
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
&MainWindow::refreshShortcuts);

View file

@ -51,6 +51,7 @@ class HandlePublicServers;
class LocalClient;
class LocalServer;
class QLabel;
class LatencyStatusWidget;
class QThread;
class RemoteClient;
class ServerInfo_User;
@ -73,7 +74,6 @@ public slots:
private slots:
void updateTabMenu(const QList<QMenu *> &newMenuList);
void statusChanged(ClientStatus _status);
void updatePingDisplay(int lastMs, int medianMs, int p95Ms, int maxMs, int sampleCount);
void localGameEnded();
void pixmapCacheSizeChanged(int newSizeInMBs);
void actDisconnect();
@ -148,8 +148,8 @@ private:
WndSets *wndSets;
ConnectionController *connectionController;
LocalServer *localServer;
QLabel *pingLabel = nullptr; ///< status bar label with live round-trip stats
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;