From 41bce91770940793dddb7721c4bd97035cd282ec Mon Sep 17 00:00:00 2001 From: sami Date: Fri, 11 Sep 2026 12:10:06 +0400 Subject: [PATCH] gui: add SegmentBarsWidget and SpeedGraphWidget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC --- gui/CMakeLists.txt | 2 + gui/src/widgets/SegmentBarsWidget.cpp | 79 ++++++++++++++++++++++ gui/src/widgets/SegmentBarsWidget.hpp | 52 +++++++++++++++ gui/src/widgets/SpeedGraphWidget.cpp | 96 +++++++++++++++++++++++++++ gui/src/widgets/SpeedGraphWidget.hpp | 51 ++++++++++++++ gui/tests/CMakeLists.txt | 26 ++++++++ gui/tests/tst_segmentbarswidget.cpp | 94 ++++++++++++++++++++++++++ gui/tests/tst_speedgraphwidget.cpp | 73 ++++++++++++++++++++ 8 files changed, 473 insertions(+) create mode 100644 gui/src/widgets/SegmentBarsWidget.cpp create mode 100644 gui/src/widgets/SegmentBarsWidget.hpp create mode 100644 gui/src/widgets/SpeedGraphWidget.cpp create mode 100644 gui/src/widgets/SpeedGraphWidget.hpp create mode 100644 gui/tests/tst_segmentbarswidget.cpp create mode 100644 gui/tests/tst_speedgraphwidget.cpp diff --git a/gui/CMakeLists.txt b/gui/CMakeLists.txt index ef7c314..9a52d47 100644 --- a/gui/CMakeLists.txt +++ b/gui/CMakeLists.txt @@ -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 ) diff --git a/gui/src/widgets/SegmentBarsWidget.cpp b/gui/src/widgets/SegmentBarsWidget.cpp new file mode 100644 index 0000000..6865c83 --- /dev/null +++ b/gui/src/widgets/SegmentBarsWidget.cpp @@ -0,0 +1,79 @@ +#include "widgets/SegmentBarsWidget.hpp" + +#include + +#include +#include +#include +#include +#include + +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(rows_.size()) <= i) { + const int idx = static_cast(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(i)]; +} + +void SegmentBarsWidget::setSegments(const std::vector &segments) { + for (std::size_t i = 0; i < segments.size(); ++i) { + Row &row = rowFor(static_cast(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(seg.downloadedBytes) / static_cast(seg.totalBytes), 0.0, + 1.0); + row.bar->setRange(0, 10000); + row.bar->setValue(static_cast(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 diff --git a/gui/src/widgets/SegmentBarsWidget.hpp b/gui/src/widgets/SegmentBarsWidget.hpp new file mode 100644 index 0000000..2dc3dfa --- /dev/null +++ b/gui/src/widgets/SegmentBarsWidget.hpp @@ -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 +#include + +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 &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 rows_; +}; + +} // namespace velox::gui diff --git a/gui/src/widgets/SpeedGraphWidget.cpp b/gui/src/widgets/SpeedGraphWidget.cpp new file mode 100644 index 0000000..555d36c --- /dev/null +++ b/gui/src/widgets/SpeedGraphWidget.cpp @@ -0,0 +1,96 @@ +#include "widgets/SpeedGraphWidget.hpp" + +#include + +#include +#include + +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(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(i)]); + } + + const double w = width(); + const double h = height(); + const double stepX = w / static_cast(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(v) / static_cast(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((oldest + i) % kWindowSeconds)]; + // Right-align: the newest sample sits at the right edge. + const double x = w - static_cast(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((oldest + i) % kWindowSeconds)]; + const double x = w - static_cast(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 diff --git a/gui/src/widgets/SpeedGraphWidget.hpp b/gui/src/widgets/SpeedGraphWidget.hpp new file mode 100644 index 0000000..468ce69 --- /dev/null +++ b/gui/src/widgets/SpeedGraphWidget.hpp @@ -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 + +#include +#include +#include + +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 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 diff --git a/gui/tests/CMakeLists.txt b/gui/tests/CMakeLists.txt index 3428ead..cd7b75e 100644 --- a/gui/tests/CMakeLists.txt +++ b/gui/tests/CMakeLists.txt @@ -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. diff --git a/gui/tests/tst_segmentbarswidget.cpp b/gui/tests/tst_segmentbarswidget.cpp new file mode 100644 index 0000000..c4c66d6 --- /dev/null +++ b/gui/tests/tst_segmentbarswidget.cpp @@ -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 +#include + +#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(); + 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(); + 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(); + 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(); + 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(); + QVERIFY(barFirst != nullptr); + + s.downloadedBytes = 40; + w.setSegments({s}); + auto *barSecond = w.findChild(); + QCOMPARE(barFirst, barSecond); // same widget instance, just repainted +} + +QTEST_MAIN(TstSegmentBarsWidget) +#include "tst_segmentbarswidget.moc" diff --git a/gui/tests/tst_speedgraphwidget.cpp b/gui/tests/tst_speedgraphwidget.cpp new file mode 100644 index 0000000..1042f5c --- /dev/null +++ b/gui/tests/tst_speedgraphwidget.cpp @@ -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 + +#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"