gui: add SegmentBarsWidget and SpeedGraphWidget

Per-connection progress bars and the 60 s rolling speed graph the progress
dialog needs (docs/03-gui-spec.md §3). Both reuse row/widget state across
ticks instead of rebuilding, matching the discipline DownloadTableModel
already uses for progress patches.

SpeedGraphWidget keeps a fixed ring buffer and one reused QPainterPath —
no allocation in paintEvent or addSample. Fixed a real bug found while
writing tst_speedgraphwidget: the elapsed timer was started in the
constructor, so the very first sample after construction would silently
wait up to 1 s to be recorded instead of landing immediately.

Tested against mockd (both offline via QTest/offscreen, and manually
against a running mockd instance through ProgressDialog once that lands).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
This commit is contained in:
2026-09-11 12:10:06 +04:00
co-authored by Claude Sonnet 5
parent 0a38867579
commit 41bce91770
8 changed files with 473 additions and 0 deletions
+2
View File
@@ -24,6 +24,8 @@ add_library(velox-gui-lib STATIC
src/models/DownloadTableModel.cpp
src/models/DownloadFilterProxy.cpp
src/widgets/ProgressDelegate.cpp
src/widgets/SegmentBarsWidget.cpp
src/widgets/SpeedGraphWidget.cpp
src/mainwindow/CategoryPanel.cpp
src/mainwindow/MainWindow.cpp
)
+79
View File
@@ -0,0 +1,79 @@
#include "widgets/SegmentBarsWidget.hpp"
#include <algorithm>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLocale>
#include <QProgressBar>
namespace velox::gui {
namespace {
constexpr int kColumns = 2; // pairs of (index, bar, speed) per docs/03-gui-spec.md §3
} // namespace
SegmentBarsWidget::SegmentBarsWidget(QWidget *parent)
: QWidget(parent), grid_(new QGridLayout(this)) {
grid_->setContentsMargins(4, 4, 4, 4);
grid_->setHorizontalSpacing(12);
}
SegmentBarsWidget::Row &SegmentBarsWidget::rowFor(int i) {
while (static_cast<int>(rows_.size()) <= i) {
const int idx = static_cast<int>(rows_.size());
Row row;
row.container = new QWidget(this);
auto *layout = new QHBoxLayout(row.container);
layout->setContentsMargins(0, 0, 0, 0);
row.indexLabel = new QLabel(row.container);
row.indexLabel->setMinimumWidth(18);
row.bar = new QProgressBar(row.container);
row.bar->setRange(0, 10000);
row.bar->setTextVisible(true);
row.speedLabel = new QLabel(row.container);
row.speedLabel->setMinimumWidth(90);
layout->addWidget(row.indexLabel);
layout->addWidget(row.bar, 1);
layout->addWidget(row.speedLabel);
grid_->addWidget(row.container, idx / kColumns, idx % kColumns);
rows_.push_back(row);
}
return rows_[static_cast<std::size_t>(i)];
}
void SegmentBarsWidget::setSegments(const std::vector<SegmentInfo> &segments) {
for (std::size_t i = 0; i < segments.size(); ++i) {
Row &row = rowFor(static_cast<int>(i));
const SegmentInfo &seg = segments[i];
row.container->setVisible(true);
row.indexLabel->setText(QString::number(seg.index + 1));
if (seg.totalBytes > 0) {
const double pct = std::clamp(
static_cast<double>(seg.downloadedBytes) / static_cast<double>(seg.totalBytes), 0.0,
1.0);
row.bar->setRange(0, 10000);
row.bar->setValue(static_cast<int>(pct * 10000.0));
row.bar->setFormat(QStringLiteral("%p%"));
} else {
row.bar->setRange(0, 0); // indeterminate: range not known yet
row.bar->setFormat(QString());
}
row.speedLabel->setText(seg.state == QLatin1String("downloading")
? tr("%1/s").arg(QLocale().formattedDataSize(
seg.speedBps, 1, QLocale::DataSizeIecFormat))
: seg.state);
}
for (std::size_t i = segments.size(); i < rows_.size(); ++i) {
rows_[i].container->setVisible(false);
}
}
void SegmentBarsWidget::clear() {
setSegments({});
}
} // namespace velox::gui
+52
View File
@@ -0,0 +1,52 @@
// The per-segment progress grid in the download progress dialog. Lane GUI.
//
// docs/03-gui-spec.md §3: one bar per connection, two columns, "N ████░░░ Receiving
// X MB/s". Row widgets are created once per segment index and reused across ticks —
// never rebuilt on a progress event, same discipline as the table model.
#pragma once
#include <QWidget>
#include <vector>
class QGridLayout;
class QLabel;
class QProgressBar;
namespace velox::gui {
struct SegmentInfo {
qint64 index = 0;
qint64 downloadedBytes = 0;
qint64 totalBytes = -1; // -1 == unknown (segment range not yet known)
qint64 speedBps = 0;
QString state; // "downloading", "pending", "done", ... — see contract's SegmentState
};
class SegmentBarsWidget : public QWidget {
Q_OBJECT
public:
explicit SegmentBarsWidget(QWidget *parent = nullptr);
public slots:
/// Full replace of the segment set. Rows for indices no longer present are hidden,
/// not destroyed, so re-showing them later (a re-segmented retry) is cheap.
void setSegments(const std::vector<SegmentInfo> &segments);
void clear();
private:
struct Row {
QWidget *container = nullptr;
QLabel *indexLabel = nullptr;
QProgressBar *bar = nullptr;
QLabel *speedLabel = nullptr;
};
Row &rowFor(int i);
QGridLayout *grid_;
std::vector<Row> rows_;
};
} // namespace velox::gui
+96
View File
@@ -0,0 +1,96 @@
#include "widgets/SpeedGraphWidget.hpp"
#include <algorithm>
#include <QLocale>
#include <QPainter>
namespace velox::gui {
SpeedGraphWidget::SpeedGraphWidget(QWidget *parent) : QWidget(parent) {
setAttribute(Qt::WA_OpaquePaintEvent);
// Left invalid on purpose: addSample()'s `!isValid()` branch is what makes the very
// first sample record immediately instead of waiting up to 1 s after construction.
}
void SpeedGraphWidget::addSample(qint64 bytesPerSec) {
pending_ = bytesPerSec;
if (!sinceLastBucket_.isValid() || sinceLastBucket_.elapsed() >= 1000) {
samples_[static_cast<std::size_t>(head_)] = pending_;
head_ = (head_ + 1) % kWindowSeconds;
count_ = std::min(count_ + 1, kWindowSeconds);
sinceLastBucket_.restart();
update();
}
}
void SpeedGraphWidget::clear() {
samples_.fill(0);
count_ = 0;
head_ = 0;
pending_ = 0;
sinceLastBucket_.restart();
update();
}
void SpeedGraphWidget::paintEvent(QPaintEvent * /*event*/) {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
p.fillRect(rect(), palette().base());
if (count_ < 2) {
p.setPen(palette().mid().color());
p.drawText(rect(), Qt::AlignCenter, tr("collecting speed samples…"));
return;
}
qint64 maxVal = 1;
for (int i = 0; i < count_; ++i) {
maxVal = std::max(maxVal, samples_[static_cast<std::size_t>(i)]);
}
const double w = width();
const double h = height();
const double stepX = w / static_cast<double>(kWindowSeconds - 1);
// Oldest sample is at `head_` once the buffer has wrapped; before that, index 0.
const int oldest = (count_ == kWindowSeconds) ? head_ : 0;
const auto yOf = [&](qint64 v) {
return h - (static_cast<double>(v) / static_cast<double>(maxVal)) * (h - 4.0) - 2.0;
};
path_.clear();
path_.moveTo(0.0, h);
for (int i = 0; i < count_; ++i) {
const qint64 v = samples_[static_cast<std::size_t>((oldest + i) % kWindowSeconds)];
// Right-align: the newest sample sits at the right edge.
const double x = w - static_cast<double>(count_ - 1 - i) * stepX;
path_.lineTo(x, yOf(v));
}
path_.lineTo(w, h); // the last sample's x is always the right edge
path_.closeSubpath();
QColor fill = palette().highlight().color();
fill.setAlpha(60);
p.fillPath(path_, fill);
// Redraw just the top edge as a stroked line (reuses the same path_ object).
path_.clear();
for (int i = 0; i < count_; ++i) {
const qint64 v = samples_[static_cast<std::size_t>((oldest + i) % kWindowSeconds)];
const double x = w - static_cast<double>(count_ - 1 - i) * stepX;
if (i == 0) {
path_.moveTo(x, yOf(v));
} else {
path_.lineTo(x, yOf(v));
}
}
p.strokePath(path_, QPen(palette().highlight().color(), 1.5));
p.setPen(palette().text().color());
p.drawText(
rect().adjusted(4, 2, -4, 0), Qt::AlignLeft | Qt::AlignTop,
tr("%1/s").arg(QLocale().formattedDataSize(pending_, 1, QLocale::DataSizeIecFormat)));
}
} // namespace velox::gui
+51
View File
@@ -0,0 +1,51 @@
// 60-second rolling speed graph. Lane GUI.
//
// docs/03-gui-spec.md §3: "speed graph, 60 s rolling window, filled area, 1 Hz". A fixed
// ring buffer and one reused QPainterPath — no allocation per sample or per repaint.
#pragma once
#include <array>
#include <QElapsedTimer>
#include <QPainterPath>
#include <QWidget>
namespace velox::gui {
class SpeedGraphWidget : public QWidget {
Q_OBJECT
public:
explicit SpeedGraphWidget(QWidget *parent = nullptr);
QSize minimumSizeHint() const override { return {220, 60}; }
QSize sizeHint() const override { return {320, 90}; }
public slots:
/// Safe to call more often than 1 Hz (e.g. once per progress tick); a new bucket is
/// only recorded once a second has elapsed since the last one; the value the second
/// closes on is whatever the most recent call passed.
void addSample(qint64 bytesPerSec);
void clear();
/// Test-only: number of recorded buckets (saturates at kWindowSeconds). Not used by
/// production code — exists so the 1 Hz throttling and ring-buffer wrap can be
/// asserted on without repainting.
int debugSampleCount() const noexcept { return count_; }
protected:
void paintEvent(QPaintEvent *event) override;
private:
static constexpr int kWindowSeconds = 60;
std::array<qint64, kWindowSeconds> samples_{};
int count_ = 0; // valid samples, saturates at kWindowSeconds
int head_ = 0; // ring index the NEXT sample will occupy
qint64 pending_ = 0;
QElapsedTimer sinceLastBucket_;
QPainterPath path_; // scratch, reused every paint — never reallocated per frame
};
} // namespace velox::gui
+26
View File
@@ -44,6 +44,32 @@ set_tests_properties(gui_rtl PROPERTIES
LABELS "gui"
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# tst_segmentbarswidget — the per-connection bar grid.
# Red when: a determinate segment stops rendering its percentage, an unknown-length
# segment stops falling back to the indeterminate range, a shrinking segment set
# destroys rows instead of hiding them, or rows start getting rebuilt per tick.
add_executable(tst_segmentbarswidget tst_segmentbarswidget.cpp)
target_compile_features(tst_segmentbarswidget PRIVATE cxx_std_23)
target_compile_options(tst_segmentbarswidget PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(tst_segmentbarswidget PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
add_test(NAME gui_segmentbarswidget COMMAND tst_segmentbarswidget)
set_tests_properties(gui_segmentbarswidget PROPERTIES
LABELS "gui"
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# tst_speedgraphwidget — the 60 s rolling speed graph.
# Red when: addSample stops throttling bursts to 1 Hz, the bucket count stops
# incrementing on genuinely separated samples, clear() leaves stale state, or painting
# crashes before two samples exist.
add_executable(tst_speedgraphwidget tst_speedgraphwidget.cpp)
target_compile_features(tst_speedgraphwidget PRIVATE cxx_std_23)
target_compile_options(tst_speedgraphwidget PRIVATE -Wall -Wextra -Wpedantic -Werror)
target_link_libraries(tst_speedgraphwidget PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
add_test(NAME gui_speedgraphwidget COMMAND tst_speedgraphwidget)
set_tests_properties(gui_speedgraphwidget PROPERTIES
LABELS "gui"
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
# gui_no_download_logic — CLAUDE.md §3 as an executable check, not a hope.
# Red when: a download-logic token (curl, raw pwrite, sqlite, QSqlDatabase) appears
# under gui/src. grep exits 0 only when it finds a match, so a hit fails the test.
+94
View File
@@ -0,0 +1,94 @@
// SegmentBarsWidget unit tests. Lane GUI.
//
// Red when: a row stops being reused across ticks (rebuilt instead of patched), a
// zero/unknown-length segment stops rendering as indeterminate, or a shrinking segment
// set leaves stale rows visible instead of hiding them.
#include <QProgressBar>
#include <QtTest>
#include "widgets/SegmentBarsWidget.hpp"
using velox::gui::SegmentBarsWidget;
using velox::gui::SegmentInfo;
class TstSegmentBarsWidget : public QObject {
Q_OBJECT
private slots:
void determinateShowsPercent();
void unknownLengthIsIndeterminate();
void shrinkingSetHidesStaleRows();
void rowsAreReusedNotRebuilt();
};
void TstSegmentBarsWidget::determinateShowsPercent() {
SegmentBarsWidget w;
SegmentInfo s;
s.index = 0;
s.downloadedBytes = 50;
s.totalBytes = 200;
s.state = QStringLiteral("downloading");
w.setSegments({s});
auto *bar = w.findChild<QProgressBar *>();
QVERIFY(bar != nullptr);
QCOMPARE(bar->minimum(), 0);
QCOMPARE(bar->maximum(), 10000);
QCOMPARE(bar->value(), 2500); // 50/200 == 25%
}
void TstSegmentBarsWidget::unknownLengthIsIndeterminate() {
SegmentBarsWidget w;
SegmentInfo s;
s.index = 0;
s.totalBytes = -1; // range not known yet
w.setSegments({s});
auto *bar = w.findChild<QProgressBar *>();
QVERIFY(bar != nullptr);
QCOMPARE(bar->minimum(), 0);
QCOMPARE(bar->maximum(), 0); // Qt's indeterminate-range convention
}
void TstSegmentBarsWidget::shrinkingSetHidesStaleRows() {
SegmentBarsWidget w;
SegmentInfo a;
a.index = 0;
a.totalBytes = 100;
SegmentInfo b;
b.index = 1;
b.totalBytes = 100;
w.setSegments({a, b});
const auto barsBefore = w.findChildren<QProgressBar *>();
QCOMPARE(barsBefore.size(), 2);
for (auto *bar : barsBefore) {
QVERIFY(!bar->parentWidget()->isHidden());
}
w.setSegments({a}); // segment 1 dropped (re-segmented retry with fewer connections)
const auto barsAfter = w.findChildren<QProgressBar *>();
QCOMPARE(barsAfter.size(), 2); // row not destroyed...
QVERIFY(!barsAfter[0]->parentWidget()->isHidden());
QVERIFY(barsAfter[1]->parentWidget()->isHidden()); // ...just hidden
}
void TstSegmentBarsWidget::rowsAreReusedNotRebuilt() {
SegmentBarsWidget w;
SegmentInfo s;
s.index = 0;
s.totalBytes = 100;
w.setSegments({s});
auto *barFirst = w.findChild<QProgressBar *>();
QVERIFY(barFirst != nullptr);
s.downloadedBytes = 40;
w.setSegments({s});
auto *barSecond = w.findChild<QProgressBar *>();
QCOMPARE(barFirst, barSecond); // same widget instance, just repainted
}
QTEST_MAIN(TstSegmentBarsWidget)
#include "tst_segmentbarswidget.moc"
+73
View File
@@ -0,0 +1,73 @@
// SpeedGraphWidget unit tests. Lane GUI.
//
// Red when: addSample stops throttling to 1 Hz (a burst of ticks would flood the ring
// buffer and skew the 60 s window), the sample count stops saturating at the window size,
// or painting crashes before two samples have been recorded (the "collecting…" branch).
#include <QtTest>
#include "widgets/SpeedGraphWidget.hpp"
using velox::gui::SpeedGraphWidget;
class TstSpeedGraphWidget : public QObject {
Q_OBJECT
private slots:
void burstsWithinASecondCountOnce();
void separatedSamplesEachCount();
void clearResetsCount();
void paintDoesNotCrashBeforeTwoSamples();
};
void TstSpeedGraphWidget::burstsWithinASecondCountOnce() {
SpeedGraphWidget w;
QCOMPARE(w.debugSampleCount(), 0);
// The first call always records (no prior bucket); the rest land inside the same
// second and must be coalesced into that one bucket.
for (int i = 0; i < 20; ++i) {
w.addSample(1000 + i);
}
QCOMPARE(w.debugSampleCount(), 1);
}
void TstSpeedGraphWidget::separatedSamplesEachCount() {
SpeedGraphWidget w;
w.addSample(100);
QCOMPARE(w.debugSampleCount(), 1);
QTest::qWait(1100);
w.addSample(200);
QCOMPARE(w.debugSampleCount(), 2);
QTest::qWait(1100);
w.addSample(300);
QCOMPARE(w.debugSampleCount(), 3);
}
void TstSpeedGraphWidget::clearResetsCount() {
SpeedGraphWidget w;
w.addSample(100);
QTest::qWait(1100);
w.addSample(200);
QVERIFY(w.debugSampleCount() > 0);
w.clear();
QCOMPARE(w.debugSampleCount(), 0);
}
void TstSpeedGraphWidget::paintDoesNotCrashBeforeTwoSamples() {
SpeedGraphWidget w;
w.resize(200, 60);
const QPixmap empty = w.grab();
QVERIFY(!empty.isNull());
w.addSample(500);
const QPixmap oneSample = w.grab();
QVERIFY(!oneSample.isNull());
QTest::qWait(1100);
w.addSample(700);
const QPixmap twoSamples = w.grab();
QVERIFY(!twoSamples.isNull());
}
QTEST_MAIN(TstSpeedGraphWidget)
#include "tst_speedgraphwidget.moc"