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