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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user