merge: lane/gui
This commit is contained in:
@@ -24,6 +24,11 @@ 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/dialogs/AddUrlDialog.cpp
|
||||
src/dialogs/FileInfoDialog.cpp
|
||||
src/dialogs/ProgressDialog.cpp
|
||||
src/mainwindow/CategoryPanel.cpp
|
||||
src/mainwindow/MainWindow.cpp
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#include "dialogs/AddUrlDialog.hpp"
|
||||
|
||||
#include <QClipboard>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QGuiApplication>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
#include <QUrl>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
QString clipboardUrlOrEmpty() {
|
||||
const QString text = QGuiApplication::clipboard()->text().trimmed();
|
||||
const QUrl url(text);
|
||||
if (url.isValid() &&
|
||||
(url.scheme() == QLatin1String("http") || url.scheme() == QLatin1String("https"))) {
|
||||
return text;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AddUrlDialog::AddUrlDialog(QWidget *parent)
|
||||
: QDialog(parent), urlEdit_(new QLineEdit(this)), buttons_(new QDialogButtonBox(this)) {
|
||||
setWindowTitle(tr("Add URL"));
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addWidget(new QLabel(tr("Enter the URL to download:"), this));
|
||||
urlEdit_->setPlaceholderText(tr("https://example.com/file.iso"));
|
||||
urlEdit_->setText(clipboardUrlOrEmpty());
|
||||
urlEdit_->selectAll();
|
||||
layout->addWidget(urlEdit_);
|
||||
|
||||
buttons_->setStandardButtons(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||
layout->addWidget(buttons_);
|
||||
connect(buttons_, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
connect(buttons_, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
|
||||
const auto updateOkEnabled = [this] {
|
||||
const QUrl u(urlEdit_->text().trimmed());
|
||||
buttons_->button(QDialogButtonBox::Ok)
|
||||
->setEnabled(u.isValid() && !u.scheme().isEmpty() && !u.host().isEmpty());
|
||||
};
|
||||
connect(urlEdit_, &QLineEdit::textChanged, this, updateOkEnabled);
|
||||
updateOkEnabled();
|
||||
|
||||
resize(480, sizeHint().height());
|
||||
}
|
||||
|
||||
QString AddUrlDialog::url() const {
|
||||
return urlEdit_->text().trimmed();
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,30 @@
|
||||
// "Add URL" — the first of the paired Add-URL / File-Info dialogs. Lane GUI.
|
||||
//
|
||||
// docs/06-risks-and-spikes.md R2: pre-filling this from the clipboard on open is one of
|
||||
// the two *explicit* clipboard paths the GUI relies on (passive monitoring is best-effort
|
||||
// and not implemented yet). This dialog does nothing else — no probing, no saving; that is
|
||||
// FileInfoDialog's job once this returns a URL.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
|
||||
class QLineEdit;
|
||||
class QDialogButtonBox;
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
class AddUrlDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit AddUrlDialog(QWidget *parent = nullptr);
|
||||
|
||||
QString url() const;
|
||||
|
||||
private:
|
||||
QLineEdit *urlEdit_;
|
||||
QDialogButtonBox *buttons_;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,268 @@
|
||||
#include "dialogs/FileInfoDialog.hpp"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QFileDialog>
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QJsonArray>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QLocale>
|
||||
#include <QMenu>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QSpinBox>
|
||||
#include <QToolButton>
|
||||
#include <QUrl>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "rpc/RpcClient.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
QString filenameFromUrl(const QString &url) {
|
||||
const QString path = QUrl(url).path();
|
||||
const QString name = path.section('/', -1);
|
||||
return name.isEmpty() ? QStringLiteral("download") : name;
|
||||
}
|
||||
|
||||
struct BufferChoice {
|
||||
const char *label;
|
||||
qint64 bytes;
|
||||
};
|
||||
constexpr BufferChoice kBufferChoices[] = {
|
||||
{"256 KiB", 256 * 1024}, {"512 KiB", 512 * 1024}, {"1 MiB", 1024 * 1024},
|
||||
{"2 MiB", 2 * 1024 * 1024}, {"4 MiB", 4 * 1024 * 1024}, {"8 MiB", 8 * 1024 * 1024},
|
||||
{"16 MiB", 16 * 1024 * 1024},
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
FileInfoDialog::FileInfoDialog(rpc::RpcClient *client, QString url, QJsonArray categories,
|
||||
QJsonArray queues, QWidget *parent)
|
||||
: QDialog(parent),
|
||||
client_(client),
|
||||
url_(std::move(url)),
|
||||
filenameEdit_(new QLineEdit(this)),
|
||||
saveDirEdit_(new QLineEdit(this)),
|
||||
categoryCombo_(new QComboBox(this)),
|
||||
sizeLabel_(new QLabel(tr("Probing…"), this)),
|
||||
resumeLabel_(new QLabel(tr("Probing…"), this)),
|
||||
descriptionEdit_(new QLineEdit(this)),
|
||||
connectionsSpin_(new QSpinBox(this)),
|
||||
bufferCombo_(new QComboBox(this)),
|
||||
addToQueueButton_(new QToolButton(this)),
|
||||
errorLabel_(new QLabel(this)) {
|
||||
setWindowTitle(tr("Download File Info"));
|
||||
|
||||
filenameEdit_->setText(filenameFromUrl(url_));
|
||||
connect(filenameEdit_, &QLineEdit::textEdited, this, [this] { filenameEditedByUser_ = true; });
|
||||
connect(saveDirEdit_, &QLineEdit::textEdited, this, [this] { saveDirEditedByUser_ = true; });
|
||||
|
||||
populateCategories(categories);
|
||||
populateQueueMenu(queues);
|
||||
|
||||
connectionsSpin_->setRange(1, 32);
|
||||
connectionsSpin_->setValue(8);
|
||||
|
||||
for (const auto &choice : kBufferChoices) {
|
||||
bufferCombo_->addItem(QString::fromLatin1(choice.label), QVariant::fromValue(choice.bytes));
|
||||
}
|
||||
bufferCombo_->setCurrentIndex(4); // 4 MiB, matching the spec's mockup default
|
||||
|
||||
auto *browse = new QPushButton(tr("Browse…"), this);
|
||||
connect(browse, &QPushButton::clicked, this, &FileInfoDialog::browseSaveDir);
|
||||
auto *saveDirRow = new QWidget(this);
|
||||
auto *saveDirLayout = new QHBoxLayout(saveDirRow);
|
||||
saveDirLayout->setContentsMargins(0, 0, 0, 0);
|
||||
saveDirLayout->addWidget(saveDirEdit_, 1);
|
||||
saveDirLayout->addWidget(browse);
|
||||
|
||||
auto *sizeResumeRow = new QWidget(this);
|
||||
auto *sizeResumeLayout = new QHBoxLayout(sizeResumeRow);
|
||||
sizeResumeLayout->setContentsMargins(0, 0, 0, 0);
|
||||
sizeResumeLayout->addWidget(sizeLabel_);
|
||||
sizeResumeLayout->addSpacing(16);
|
||||
sizeResumeLayout->addWidget(resumeLabel_);
|
||||
sizeResumeLayout->addStretch();
|
||||
|
||||
auto *remember = new QCheckBox(tr("Remember for this file type"), this);
|
||||
remember->setEnabled(false); // not wired to settings yet — Options dialog isn't built
|
||||
|
||||
auto *form = new QFormLayout;
|
||||
form->addRow(tr("File Name:"), filenameEdit_);
|
||||
form->addRow(tr("Save As:"), saveDirRow);
|
||||
form->addRow(tr("Category:"), categoryCombo_);
|
||||
form->addRow(QString(), sizeResumeRow);
|
||||
form->addRow(tr("Description:"), descriptionEdit_);
|
||||
form->addRow(tr("Connections:"), connectionsSpin_);
|
||||
form->addRow(tr("Buffer:"), bufferCombo_);
|
||||
form->addRow(QString(), remember);
|
||||
|
||||
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
||||
errorLabel_->setWordWrap(true);
|
||||
errorLabel_->hide();
|
||||
|
||||
addToQueueButton_->setText(tr("Add to Queue"));
|
||||
addToQueueButton_->setPopupMode(QToolButton::MenuButtonPopup);
|
||||
auto *downloadNow = new QPushButton(tr("Download Now"), this);
|
||||
downloadNow->setDefault(true);
|
||||
auto *downloadLater = new QPushButton(tr("Download Later"), this);
|
||||
auto *cancel = new QPushButton(tr("Cancel"), this);
|
||||
connect(downloadNow, &QPushButton::clicked, this,
|
||||
[this] { submit(QStringLiteral("now"), {}); });
|
||||
connect(downloadLater, &QPushButton::clicked, this,
|
||||
[this] { submit(QStringLiteral("later"), {}); });
|
||||
connect(cancel, &QPushButton::clicked, this, &QDialog::reject);
|
||||
|
||||
auto *buttonRow = new QHBoxLayout;
|
||||
buttonRow->addStretch();
|
||||
buttonRow->addWidget(downloadNow);
|
||||
buttonRow->addWidget(downloadLater);
|
||||
buttonRow->addWidget(addToQueueButton_);
|
||||
buttonRow->addWidget(cancel);
|
||||
|
||||
auto *outer = new QVBoxLayout(this);
|
||||
outer->addLayout(form);
|
||||
outer->addWidget(errorLabel_);
|
||||
outer->addLayout(buttonRow);
|
||||
resize(560, sizeHint().height());
|
||||
|
||||
QPointer<FileInfoDialog> self(this);
|
||||
client_->call(QStringLiteral("download.probe"), QJsonObject{{"url", url_}},
|
||||
[self](const rpc::RpcReply &reply) {
|
||||
if (!self) {
|
||||
return; // dialog closed before the probe answered
|
||||
}
|
||||
self->onProbeFinished(reply.ok(), reply.result.toObject());
|
||||
});
|
||||
}
|
||||
|
||||
void FileInfoDialog::populateCategories(const QJsonArray &categories) {
|
||||
categoryCombo_->addItem(tr("(Automatic)"), QString());
|
||||
for (const QJsonValue &v : categories) {
|
||||
const QJsonObject c = v.toObject();
|
||||
categoryCombo_->addItem(c.value("name").toString(), c.value("categoryId").toString());
|
||||
}
|
||||
}
|
||||
|
||||
void FileInfoDialog::populateQueueMenu(const QJsonArray &queues) {
|
||||
if (queues.isEmpty()) {
|
||||
addToQueueButton_->setEnabled(false);
|
||||
return;
|
||||
}
|
||||
auto *menu = new QMenu(addToQueueButton_);
|
||||
for (const QJsonValue &v : queues) {
|
||||
const QJsonObject q = v.toObject();
|
||||
const QString queueId = q.value("queueId").toString();
|
||||
menu->addAction(q.value("name").toString(), this,
|
||||
[this, queueId] { submit(QStringLiteral("queue"), queueId); });
|
||||
}
|
||||
addToQueueButton_->setMenu(menu);
|
||||
connect(addToQueueButton_, &QToolButton::clicked, addToQueueButton_, &QToolButton::showMenu);
|
||||
}
|
||||
|
||||
QString FileInfoDialog::selectedCategoryId() const {
|
||||
return categoryCombo_->currentData().toString();
|
||||
}
|
||||
|
||||
void FileInfoDialog::onProbeFinished(bool ok, const QJsonObject &result) {
|
||||
if (!ok) {
|
||||
sizeLabel_->setText(tr("Size: unknown"));
|
||||
resumeLabel_->setText(tr("Resume capability: unknown"));
|
||||
errorLabel_->setText(
|
||||
tr("Could not probe this URL; you can still try to download it as entered."));
|
||||
errorLabel_->show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filenameEditedByUser_) {
|
||||
const QString probed = result.value("filename").toString();
|
||||
if (!probed.isEmpty()) {
|
||||
filenameEdit_->setText(probed);
|
||||
}
|
||||
}
|
||||
if (!saveDirEditedByUser_) {
|
||||
const QString suggested = result.value("suggestedSaveDir").toString();
|
||||
if (!suggested.isEmpty()) {
|
||||
saveDirEdit_->setText(suggested);
|
||||
}
|
||||
}
|
||||
const QJsonValue size = result.value("sizeBytes");
|
||||
sizeLabel_->setText(tr("Size: %1")
|
||||
.arg(size.isDouble() ? QLocale().formattedDataSize(
|
||||
static_cast<qint64>(size.toDouble()), 2)
|
||||
: tr("unknown")));
|
||||
resumeLabel_->setText(
|
||||
tr("Resume capability: %1").arg(result.value("resumable").toBool() ? tr("Yes") : tr("No")));
|
||||
|
||||
const QString suggestedCategory = result.value("suggestedCategoryId").toString();
|
||||
if (!suggestedCategory.isEmpty()) {
|
||||
const int idx = categoryCombo_->findData(suggestedCategory);
|
||||
if (idx >= 0) {
|
||||
categoryCombo_->setCurrentIndex(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FileInfoDialog::browseSaveDir() {
|
||||
const QString dir =
|
||||
QFileDialog::getExistingDirectory(this, tr("Save As"), saveDirEdit_->text());
|
||||
if (!dir.isEmpty()) {
|
||||
saveDirEdit_->setText(dir);
|
||||
saveDirEditedByUser_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void FileInfoDialog::submit(const QString &startMode, const QString &queueId) {
|
||||
const QJsonObject spec = buildSpec(
|
||||
url_, filenameEdit_->text().trimmed(), saveDirEdit_->text().trimmed(), selectedCategoryId(),
|
||||
descriptionEdit_->text().trimmed(), connectionsSpin_->value(),
|
||||
bufferCombo_->currentData().toLongLong(), startMode, queueId);
|
||||
|
||||
QPointer<FileInfoDialog> self(this);
|
||||
client_->call(QStringLiteral("download.add"), spec, [self](const rpc::RpcReply &reply) {
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
if (reply.ok()) {
|
||||
self->accept();
|
||||
return;
|
||||
}
|
||||
self->errorLabel_->setText(tr("Could not add the download: %1").arg(reply.error.message));
|
||||
self->errorLabel_->show();
|
||||
});
|
||||
}
|
||||
|
||||
QJsonObject FileInfoDialog::buildSpec(const QString &url, const QString &filename,
|
||||
const QString &saveDir, const QString &categoryId,
|
||||
const QString &description, int segments, qint64 bufferBytes,
|
||||
const QString &startMode, const QString &queueId) {
|
||||
QJsonObject spec{{"url", url}, {"startMode", startMode}};
|
||||
if (!filename.isEmpty()) {
|
||||
spec["filename"] = filename;
|
||||
}
|
||||
if (!saveDir.isEmpty()) {
|
||||
spec["saveDir"] = saveDir;
|
||||
}
|
||||
if (!categoryId.isEmpty()) {
|
||||
spec["categoryId"] = categoryId;
|
||||
}
|
||||
if (!description.isEmpty()) {
|
||||
spec["description"] = description;
|
||||
}
|
||||
if (segments > 0) {
|
||||
spec["segments"] = segments;
|
||||
}
|
||||
if (bufferBytes > 0) {
|
||||
spec["bufferBytes"] = bufferBytes;
|
||||
}
|
||||
if (startMode == QLatin1String("queue") && !queueId.isEmpty()) {
|
||||
spec["queueId"] = queueId;
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,66 @@
|
||||
// "Download File Info" — IDM's signature dialog. Lane GUI.
|
||||
//
|
||||
// docs/03-gui-spec.md §2. Opens immediately with a spinner in Size/Resume capability;
|
||||
// download.probe runs async and never blocks the UI thread. Ends by calling download.add
|
||||
// itself (Download Now / Download Later / Add to Queue) so MainWindow only has to open it.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
class QComboBox;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QSpinBox;
|
||||
class QToolButton;
|
||||
|
||||
namespace velox::gui {
|
||||
namespace rpc {
|
||||
class RpcClient;
|
||||
} // namespace rpc
|
||||
|
||||
class FileInfoDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
FileInfoDialog(rpc::RpcClient *client, QString url, QJsonArray categories, QJsonArray queues,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
/// Pure: the DownloadSpec params for download.add, built from already-resolved values —
|
||||
/// no widget access, so it is unit-testable on its own.
|
||||
static QJsonObject buildSpec(const QString &url, const QString &filename,
|
||||
const QString &saveDir, const QString &categoryId,
|
||||
const QString &description, int segments, qint64 bufferBytes,
|
||||
const QString &startMode, const QString &queueId);
|
||||
|
||||
private slots:
|
||||
void onProbeFinished(bool ok, const QJsonObject &result);
|
||||
void browseSaveDir();
|
||||
void submit(const QString &startMode, const QString &queueId);
|
||||
|
||||
private:
|
||||
void populateCategories(const QJsonArray &categories);
|
||||
void populateQueueMenu(const QJsonArray &queues);
|
||||
QString selectedCategoryId() const;
|
||||
|
||||
rpc::RpcClient *client_;
|
||||
QString url_;
|
||||
|
||||
QLineEdit *filenameEdit_;
|
||||
QLineEdit *saveDirEdit_;
|
||||
QComboBox *categoryCombo_;
|
||||
QLabel *sizeLabel_;
|
||||
QLabel *resumeLabel_;
|
||||
QLineEdit *descriptionEdit_;
|
||||
QSpinBox *connectionsSpin_;
|
||||
QComboBox *bufferCombo_;
|
||||
QToolButton *addToQueueButton_;
|
||||
QLabel *errorLabel_;
|
||||
|
||||
bool filenameEditedByUser_ = false;
|
||||
bool saveDirEditedByUser_ = false;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,274 @@
|
||||
#include "dialogs/ProgressDialog.hpp"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QDesktopServices>
|
||||
#include <QDir>
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QJsonArray>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QUrl>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "rpc/RpcClient.hpp"
|
||||
#include "util/Format.hpp"
|
||||
#include "widgets/SegmentBarsWidget.hpp"
|
||||
#include "widgets/SpeedGraphWidget.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
enum OnCompletionAction { kDoNothing = 0, kOpenFile, kOpenFolder };
|
||||
|
||||
bool isTerminal(const QString &state) {
|
||||
return state == QLatin1String("complete") || state == QLatin1String("failed") ||
|
||||
state == QLatin1String("cancelled");
|
||||
}
|
||||
|
||||
QString stateText(const QString &state) {
|
||||
static const QHash<QString, QString> kLabels = {
|
||||
{QStringLiteral("queued"), ProgressDialog::tr("Queued")},
|
||||
{QStringLiteral("connecting"), ProgressDialog::tr("Connecting…")},
|
||||
{QStringLiteral("downloading"), ProgressDialog::tr("Receiving data…")},
|
||||
{QStringLiteral("paused"), ProgressDialog::tr("Paused")},
|
||||
{QStringLiteral("retry_wait"), ProgressDialog::tr("Waiting to retry…")},
|
||||
{QStringLiteral("assembling"), ProgressDialog::tr("Assembling…")},
|
||||
{QStringLiteral("verifying"), ProgressDialog::tr("Verifying…")},
|
||||
{QStringLiteral("complete"), ProgressDialog::tr("Complete")},
|
||||
{QStringLiteral("failed"), ProgressDialog::tr("Failed")},
|
||||
{QStringLiteral("cancelled"), ProgressDialog::tr("Cancelled")},
|
||||
};
|
||||
return kLabels.value(state, state);
|
||||
}
|
||||
|
||||
std::vector<SegmentInfo> segmentsFromDetail(const QJsonArray &detail) {
|
||||
std::vector<SegmentInfo> out;
|
||||
out.reserve(static_cast<std::size_t>(detail.size()));
|
||||
for (const QJsonValue &v : detail) {
|
||||
const QJsonObject s = v.toObject();
|
||||
SegmentInfo info;
|
||||
info.index = static_cast<qint64>(s.value("index").toDouble());
|
||||
info.downloadedBytes = static_cast<qint64>(s.value("downloadedBytes").toDouble());
|
||||
const qint64 start = static_cast<qint64>(s.value("startByte").toDouble());
|
||||
const qint64 end = static_cast<qint64>(s.value("endByte").toDouble());
|
||||
info.totalBytes = end >= start ? (end - start + 1) : -1;
|
||||
info.speedBps = static_cast<qint64>(s.value("speedBps").toDouble());
|
||||
info.state = s.value("state").toString();
|
||||
out.push_back(info);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ProgressDialog::ProgressDialog(rpc::RpcClient *client, QString taskId, QWidget *parent)
|
||||
: QDialog(parent),
|
||||
client_(client),
|
||||
taskId_(std::move(taskId)),
|
||||
urlLabel_(new QLabel(this)),
|
||||
statusLabel_(new QLabel(this)),
|
||||
fileSizeLabel_(new QLabel(this)),
|
||||
downloadedLabel_(new QLabel(this)),
|
||||
rateLabel_(new QLabel(this)),
|
||||
timeLeftLabel_(new QLabel(this)),
|
||||
resumeLabel_(new QLabel(this)),
|
||||
segmentsWidget_(new SegmentBarsWidget(this)),
|
||||
speedGraph_(new SpeedGraphWidget(this)),
|
||||
closeWhenDone_(new QCheckBox(tr("Close dialog when done"), this)),
|
||||
onCompletion_(new QComboBox(this)),
|
||||
pauseButton_(new QPushButton(tr("Pause"), this)),
|
||||
cancelButton_(new QPushButton(tr("Cancel"), this)) {
|
||||
setAttribute(Qt::WA_DeleteOnClose);
|
||||
setWindowTitle(tr("Download Progress"));
|
||||
resize(560, 480);
|
||||
|
||||
urlLabel_->setWordWrap(true);
|
||||
urlLabel_->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||
|
||||
auto *form = new QFormLayout;
|
||||
form->addRow(tr("URL"), urlLabel_);
|
||||
form->addRow(tr("Status"), statusLabel_);
|
||||
form->addRow(tr("File size"), fileSizeLabel_);
|
||||
form->addRow(tr("Downloaded"), downloadedLabel_);
|
||||
form->addRow(tr("Transfer rate"), rateLabel_);
|
||||
form->addRow(tr("Time left"), timeLeftLabel_);
|
||||
form->addRow(tr("Resume capability"), resumeLabel_);
|
||||
|
||||
onCompletion_->addItem(tr("Do nothing"), kDoNothing);
|
||||
onCompletion_->addItem(tr("Open file"), kOpenFile);
|
||||
onCompletion_->addItem(tr("Open folder"), kOpenFolder);
|
||||
// "Exit Velox" / "Shut down" wait on the tray + session integration (build order
|
||||
// step 6) — offering them here with no backing action would be a fake affordance.
|
||||
|
||||
auto *bottomRow = new QHBoxLayout;
|
||||
bottomRow->addWidget(closeWhenDone_);
|
||||
bottomRow->addStretch();
|
||||
bottomRow->addWidget(new QLabel(tr("On completion:"), this));
|
||||
bottomRow->addWidget(onCompletion_);
|
||||
|
||||
auto *hideButton = new QPushButton(tr("Hide"), this);
|
||||
connect(hideButton, &QPushButton::clicked, this, &QDialog::close);
|
||||
connect(pauseButton_, &QPushButton::clicked, this, &ProgressDialog::togglePause);
|
||||
connect(cancelButton_, &QPushButton::clicked, this, &ProgressDialog::cancelTask);
|
||||
|
||||
auto *actionRow = new QHBoxLayout;
|
||||
actionRow->addStretch();
|
||||
actionRow->addWidget(pauseButton_);
|
||||
actionRow->addWidget(cancelButton_);
|
||||
actionRow->addWidget(hideButton);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addLayout(form);
|
||||
layout->addWidget(segmentsWidget_, 1);
|
||||
layout->addWidget(speedGraph_);
|
||||
layout->addLayout(bottomRow);
|
||||
layout->addLayout(actionRow);
|
||||
|
||||
connect(client_, &rpc::RpcClient::taskProgress, this, &ProgressDialog::onTaskProgress);
|
||||
connect(client_, &rpc::RpcClient::taskStateChanged, this, &ProgressDialog::onTaskState);
|
||||
|
||||
QPointer<ProgressDialog> self(this);
|
||||
client_->call(QStringLiteral("download.get"), QJsonObject{{"taskId", taskId_}},
|
||||
[self](const rpc::RpcReply &reply) {
|
||||
if (!self || !reply.ok()) {
|
||||
return;
|
||||
}
|
||||
const QJsonObject result = reply.result.toObject();
|
||||
self->applySummary(result.value("summary").toObject());
|
||||
self->segments_ = segmentsFromDetail(result.value("segmentDetail").toArray());
|
||||
self->segmentsWidget_->setSegments(self->segments_);
|
||||
});
|
||||
}
|
||||
|
||||
void ProgressDialog::applySummary(const QJsonObject &summary) {
|
||||
if (summary.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
urlLabel_->setText(summary.value("url").toString());
|
||||
filename_ = summary.value("filename").toString();
|
||||
saveDir_ = summary.value("saveDir").toString();
|
||||
if (!filename_.isEmpty()) {
|
||||
setWindowTitle(filename_);
|
||||
}
|
||||
state_ = summary.value("state").toString();
|
||||
const QJsonValue size = summary.value("sizeBytes");
|
||||
sizeBytes_ = size.isDouble() ? static_cast<qint64>(size.toDouble()) : -1;
|
||||
downloadedBytes_ = static_cast<qint64>(summary.value("downloadedBytes").toDouble());
|
||||
speedBps_ = static_cast<qint64>(summary.value("speedBps").toDouble());
|
||||
const QJsonValue eta = summary.value("etaSeconds");
|
||||
etaSeconds_ = eta.isDouble() ? static_cast<qint64>(eta.toDouble()) : -1;
|
||||
resumable_ = summary.value("resumable").toBool();
|
||||
refreshLabels();
|
||||
}
|
||||
|
||||
void ProgressDialog::refreshLabels() {
|
||||
statusLabel_->setText(stateText(state_));
|
||||
fileSizeLabel_->setText(fmt::bytes(sizeBytes_));
|
||||
if (sizeBytes_ > 0) {
|
||||
const double pct =
|
||||
100.0 * static_cast<double>(downloadedBytes_) / static_cast<double>(sizeBytes_);
|
||||
downloadedLabel_->setText(
|
||||
tr("%1 (%2 %)").arg(fmt::bytes(downloadedBytes_)).arg(pct, 0, 'f', 2));
|
||||
} else {
|
||||
downloadedLabel_->setText(fmt::bytes(downloadedBytes_));
|
||||
}
|
||||
rateLabel_->setText(state_ == QLatin1String("downloading") ? fmt::rate(speedBps_) : QString());
|
||||
timeLeftLabel_->setText(state_ == QLatin1String("downloading") ? fmt::eta(etaSeconds_)
|
||||
: QString());
|
||||
resumeLabel_->setText(resumable_ ? tr("Yes") : tr("No"));
|
||||
|
||||
const bool terminal = isTerminal(state_);
|
||||
pauseButton_->setText(state_ == QLatin1String("paused") ? tr("Resume") : tr("Pause"));
|
||||
pauseButton_->setEnabled(!terminal);
|
||||
cancelButton_->setEnabled(!terminal);
|
||||
}
|
||||
|
||||
void ProgressDialog::onTaskProgress(const QJsonArray &tasks) {
|
||||
for (const QJsonValue &v : tasks) {
|
||||
const QJsonObject t = v.toObject();
|
||||
if (t.value("taskId").toString() != taskId_) {
|
||||
continue;
|
||||
}
|
||||
downloadedBytes_ = static_cast<qint64>(t.value("downloadedBytes").toDouble());
|
||||
speedBps_ = static_cast<qint64>(t.value("speedBps").toDouble());
|
||||
const QJsonValue eta = t.value("etaSeconds");
|
||||
etaSeconds_ = eta.isDouble() ? static_cast<qint64>(eta.toDouble()) : -1;
|
||||
state_ = QStringLiteral("downloading"); // event.task.progress only fires while active
|
||||
|
||||
for (const QJsonValue &sv : t.value("segments").toArray()) {
|
||||
const QJsonObject s = sv.toObject();
|
||||
const qint64 idx = static_cast<qint64>(s.value("index").toDouble());
|
||||
for (auto &seg : segments_) {
|
||||
if (seg.index == idx) {
|
||||
seg.downloadedBytes =
|
||||
static_cast<qint64>(s.value("downloadedBytes").toDouble());
|
||||
seg.speedBps = static_cast<qint64>(s.value("speedBps").toDouble());
|
||||
seg.state = QStringLiteral("downloading");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
segmentsWidget_->setSegments(segments_);
|
||||
speedGraph_->addSample(speedBps_);
|
||||
refreshLabels();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void ProgressDialog::onTaskState(const QJsonObject ¶ms) {
|
||||
if (params.value("taskId").toString() != taskId_) {
|
||||
return;
|
||||
}
|
||||
const QJsonObject summary = params.value("summary").toObject();
|
||||
if (!summary.isEmpty()) {
|
||||
applySummary(summary);
|
||||
} else {
|
||||
state_ = params.value("state").toString();
|
||||
refreshLabels();
|
||||
}
|
||||
if (isTerminal(state_)) {
|
||||
handleTerminalState();
|
||||
}
|
||||
}
|
||||
|
||||
void ProgressDialog::handleTerminalState() {
|
||||
if (state_ == QLatin1String("complete") && !saveDir_.isEmpty() && !filename_.isEmpty()) {
|
||||
const QString path = QDir(saveDir_).filePath(filename_);
|
||||
switch (onCompletion_->currentData().toInt()) {
|
||||
case kOpenFile:
|
||||
QDesktopServices::openUrl(QUrl::fromLocalFile(path));
|
||||
break;
|
||||
case kOpenFolder:
|
||||
QDesktopServices::openUrl(QUrl::fromLocalFile(saveDir_));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (closeWhenDone_->isChecked()) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
void ProgressDialog::togglePause() {
|
||||
const bool paused = state_ == QLatin1String("paused");
|
||||
client_->call(
|
||||
QString::fromLatin1(paused ? rpc::method::kDownloadResume : rpc::method::kDownloadPause),
|
||||
QJsonObject{{"taskIds", QJsonArray{taskId_}}});
|
||||
}
|
||||
|
||||
void ProgressDialog::cancelTask() {
|
||||
if (QMessageBox::question(this, tr("Stop download"),
|
||||
tr("Stop this download? Partial data is kept.")) !=
|
||||
QMessageBox::Yes) {
|
||||
return;
|
||||
}
|
||||
client_->call(QString::fromLatin1(rpc::method::kDownloadCancel),
|
||||
QJsonObject{{"taskIds", QJsonArray{taskId_}}});
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,75 @@
|
||||
// The per-task download progress dialog. Lane GUI.
|
||||
//
|
||||
// docs/03-gui-spec.md §3. Non-modal (Qt::WA_DeleteOnClose, shown not exec()'d) so several
|
||||
// can be open at once and none of them block the main window. Driven entirely by the
|
||||
// events RpcClient already re-broadcasts — download.get is called exactly once, to seed
|
||||
// state for a dialog opened on a task that is already mid-transfer.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <vector>
|
||||
|
||||
#include "widgets/SegmentBarsWidget.hpp"
|
||||
|
||||
class QCheckBox;
|
||||
class QComboBox;
|
||||
class QLabel;
|
||||
class QPushButton;
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
class SegmentBarsWidget;
|
||||
class SpeedGraphWidget;
|
||||
namespace rpc {
|
||||
class RpcClient;
|
||||
} // namespace rpc
|
||||
|
||||
class ProgressDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ProgressDialog(rpc::RpcClient *client, QString taskId, QWidget *parent = nullptr);
|
||||
|
||||
private slots:
|
||||
void onTaskProgress(const QJsonArray &tasks);
|
||||
void onTaskState(const QJsonObject ¶ms);
|
||||
void togglePause();
|
||||
void cancelTask();
|
||||
|
||||
private:
|
||||
void applySummary(const QJsonObject &summary);
|
||||
void refreshLabels();
|
||||
void handleTerminalState();
|
||||
|
||||
rpc::RpcClient *client_;
|
||||
QString taskId_;
|
||||
|
||||
QLabel *urlLabel_;
|
||||
QLabel *statusLabel_;
|
||||
QLabel *fileSizeLabel_;
|
||||
QLabel *downloadedLabel_;
|
||||
QLabel *rateLabel_;
|
||||
QLabel *timeLeftLabel_;
|
||||
QLabel *resumeLabel_;
|
||||
SegmentBarsWidget *segmentsWidget_;
|
||||
SpeedGraphWidget *speedGraph_;
|
||||
QCheckBox *closeWhenDone_;
|
||||
QComboBox *onCompletion_;
|
||||
QPushButton *pauseButton_;
|
||||
QPushButton *cancelButton_;
|
||||
|
||||
QString state_;
|
||||
QString saveDir_;
|
||||
QString filename_;
|
||||
qint64 sizeBytes_ = -1;
|
||||
qint64 downloadedBytes_ = 0;
|
||||
qint64 speedBps_ = 0;
|
||||
qint64 etaSeconds_ = -1;
|
||||
bool resumable_ = false;
|
||||
std::vector<SegmentInfo> segments_;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -22,22 +22,18 @@
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
#include "dialogs/AddUrlDialog.hpp"
|
||||
#include "dialogs/FileInfoDialog.hpp"
|
||||
#include "dialogs/ProgressDialog.hpp"
|
||||
#include "mainwindow/CategoryPanel.hpp"
|
||||
#include "models/DownloadTableModel.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
#include "util/Format.hpp"
|
||||
#include "widgets/ProgressDelegate.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
QString humanRate(qint64 bps) {
|
||||
if (bps <= 0) {
|
||||
return QStringLiteral("0 B/s");
|
||||
}
|
||||
return MainWindow::tr("%1/s").arg(
|
||||
QLocale().formattedDataSize(bps, 1, QLocale::DataSizeIecFormat));
|
||||
}
|
||||
|
||||
QString dotStyle(const QString &colour) {
|
||||
return QStringLiteral("color: %1; font-size: 14px;").arg(colour);
|
||||
}
|
||||
@@ -70,6 +66,10 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
|
||||
view_->setSelectionMode(QAbstractItemView::ExtendedSelection);
|
||||
view_->setSortingEnabled(true);
|
||||
view_->setItemDelegateForColumn(DownloadTableModel::ColStatus, new ProgressDelegate(this));
|
||||
view_->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
connect(view_, &QWidget::customContextMenuRequested, this, &MainWindow::showTableContextMenu);
|
||||
connect(view_, &QAbstractItemView::doubleClicked, this,
|
||||
&MainWindow::openPropertiesForSelection);
|
||||
view_->header()->setSectionsMovable(true);
|
||||
view_->header()->setStretchLastSection(true);
|
||||
|
||||
@@ -141,7 +141,7 @@ MainWindow::~MainWindow() = default;
|
||||
void MainWindow::buildActions() {
|
||||
actAddUrl_ = new QAction(tr("Add &URL…"), this);
|
||||
actAddUrl_->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_N));
|
||||
actAddUrl_->setEnabled(false); // wired up with the Add URL dialog (build order step 5)
|
||||
connect(actAddUrl_, &QAction::triggered, this, &MainWindow::openAddUrlDialog);
|
||||
|
||||
actResume_ = new QAction(tr("&Resume"), this);
|
||||
actResume_->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
|
||||
@@ -163,6 +163,9 @@ void MainWindow::buildActions() {
|
||||
|
||||
actPauseAll_ = new QAction(tr("Pause All"), this);
|
||||
connect(actPauseAll_, &QAction::triggered, this, &MainWindow::pauseAll);
|
||||
|
||||
actProperties_ = new QAction(tr("&Properties…"), this);
|
||||
connect(actProperties_, &QAction::triggered, this, &MainWindow::openPropertiesForSelection);
|
||||
}
|
||||
|
||||
void MainWindow::buildMenus() {
|
||||
@@ -174,6 +177,7 @@ void MainWindow::buildMenus() {
|
||||
tasks->addAction(actStop_);
|
||||
tasks->addSeparator();
|
||||
tasks->addAction(actRemove_);
|
||||
tasks->addAction(actProperties_);
|
||||
tasks->addSeparator();
|
||||
tasks->addAction(tr("E&xit"), QKeySequence::Quit, this, &QWidget::close);
|
||||
|
||||
@@ -237,7 +241,8 @@ void MainWindow::onConnectionState(rpc::ConnectionState state) {
|
||||
: tr("The Velox service is not running. Downloads are unaffected; "
|
||||
"this list is read-only until it returns."));
|
||||
}
|
||||
for (QAction *a : {actResume_, actPause_, actStop_, actRemove_, actResumeAll_, actPauseAll_}) {
|
||||
for (QAction *a : {actResume_, actPause_, actStop_, actRemove_, actResumeAll_, actPauseAll_,
|
||||
actAddUrl_, actProperties_}) {
|
||||
a->setEnabled(online);
|
||||
}
|
||||
|
||||
@@ -249,16 +254,72 @@ void MainWindow::onConnectionState(rpc::ConnectionState state) {
|
||||
void MainWindow::fetchTree() {
|
||||
client_->call(QStringLiteral("category.list"), {}, [this](const rpc::RpcReply &reply) {
|
||||
if (reply.ok()) {
|
||||
panel_->setCategories(reply.result.toObject().value("items").toArray());
|
||||
categoriesCache_ = reply.result.toObject().value("items").toArray();
|
||||
panel_->setCategories(categoriesCache_);
|
||||
}
|
||||
});
|
||||
client_->call(QStringLiteral("queue.list"), {}, [this](const rpc::RpcReply &reply) {
|
||||
if (reply.ok()) {
|
||||
panel_->setQueues(reply.result.toObject().value("items").toArray());
|
||||
queuesCache_ = reply.result.toObject().value("items").toArray();
|
||||
panel_->setQueues(queuesCache_);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::openAddUrlDialog() {
|
||||
AddUrlDialog dlg(this);
|
||||
if (dlg.exec() != QDialog::Accepted) {
|
||||
return;
|
||||
}
|
||||
const QString url = dlg.url();
|
||||
if (url.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// Non-modal: several File Info dialogs can be open at once (spec §2), and it must
|
||||
// never block the main window while download.probe is in flight.
|
||||
auto *info = new FileInfoDialog(client_, url, categoriesCache_, queuesCache_, this);
|
||||
info->setAttribute(Qt::WA_DeleteOnClose);
|
||||
info->show();
|
||||
}
|
||||
|
||||
void MainWindow::openPropertiesForSelection() {
|
||||
const QStringList ids = selectedTaskIds();
|
||||
if (ids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// One Progress dialog per selected task, same as double-clicking each row.
|
||||
for (const QString &id : ids) {
|
||||
auto *dlg = new ProgressDialog(client_, id, this);
|
||||
dlg->show();
|
||||
}
|
||||
}
|
||||
|
||||
// docs/03-gui-spec.md §1 lists a fuller row menu (Open · Open With · Open Folder ·
|
||||
// Move/Rename · Redownload · Refresh Download Address · Add to Queue ▸ · ...). Those all
|
||||
// need dialogs or state this build order hasn't reached yet (Options for file-type
|
||||
// associations, Batch for Add to Queue) — offering them now would be a fake affordance,
|
||||
// same reasoning as the disabled Scheduler/Speed Limiter menu entries above.
|
||||
void MainWindow::showTableContextMenu(const QPoint &pos) {
|
||||
const QModelIndex idx = view_->indexAt(pos);
|
||||
if (idx.isValid() && view_->selectionModel() && !view_->selectionModel()->isSelected(idx)) {
|
||||
view_->selectionModel()->setCurrentIndex(
|
||||
idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
|
||||
}
|
||||
if (selectedTaskIds().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QMenu menu(this);
|
||||
menu.addAction(actResume_);
|
||||
menu.addAction(actPause_);
|
||||
menu.addAction(actStop_);
|
||||
menu.addSeparator();
|
||||
menu.addAction(actRemove_);
|
||||
menu.addSeparator();
|
||||
menu.addAction(actProperties_);
|
||||
menu.exec(view_->viewport()->mapToGlobal(pos));
|
||||
}
|
||||
|
||||
void MainWindow::onFilterChanged(const TaskSelection &selection) {
|
||||
proxy_->setSelection(selection);
|
||||
// TODO(gui): also push proxy_->asTaskFilter() to a fresh download.list once the daemon
|
||||
@@ -315,7 +376,7 @@ void MainWindow::refreshCounts() {
|
||||
.arg(shown)
|
||||
.arg(total)
|
||||
.arg(active)
|
||||
.arg(humanRate(globalDownBps_)));
|
||||
.arg(fmt::rate(globalDownBps_)));
|
||||
}
|
||||
|
||||
QStringList MainWindow::selectedTaskIds() const {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// The main window. Lane GUI.
|
||||
//
|
||||
// M1 slice: dialogs and the tray still come later. This is the menus + toolbar + splitter
|
||||
// (category tree | table), the table driven by mockd, a status-bar connection dot, an
|
||||
// offline banner instead of a modal, and the actions that need no dialog.
|
||||
// M1 slice: the tray and the Options/Scheduler/Speed-Limiter/Batch/Grabber dialogs still
|
||||
// come later. This is the menus + toolbar + splitter (category tree | table), the table
|
||||
// driven by mockd, a status-bar connection dot, an offline banner instead of a modal, the
|
||||
// Add URL -> File Info flow, and a per-task progress dialog.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QHash>
|
||||
#include <QJsonArray>
|
||||
#include <QMainWindow>
|
||||
#include <QPoint>
|
||||
|
||||
#include "models/DownloadFilterProxy.hpp"
|
||||
#include "rpc/Protocol.hpp"
|
||||
@@ -49,6 +52,10 @@ class MainWindow : public QMainWindow {
|
||||
void resumeAll();
|
||||
void pauseAll();
|
||||
|
||||
void openAddUrlDialog();
|
||||
void openPropertiesForSelection();
|
||||
void showTableContextMenu(const QPoint &pos);
|
||||
|
||||
private:
|
||||
void buildActions();
|
||||
void buildMenus();
|
||||
@@ -73,6 +80,10 @@ class MainWindow : public QMainWindow {
|
||||
QAction *actRemove_ = nullptr;
|
||||
QAction *actResumeAll_ = nullptr;
|
||||
QAction *actPauseAll_ = nullptr;
|
||||
QAction *actProperties_ = nullptr;
|
||||
|
||||
QJsonArray categoriesCache_;
|
||||
QJsonArray queuesCache_;
|
||||
|
||||
QLabel *connDot_;
|
||||
QLabel *connText_;
|
||||
|
||||
@@ -4,16 +4,13 @@
|
||||
|
||||
#include <QLocale>
|
||||
|
||||
#include "util/Format.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
QString humanBytes(qint64 n) {
|
||||
if (n < 0) {
|
||||
return QStringLiteral("—");
|
||||
}
|
||||
return QLocale().formattedDataSize(n, 2, QLocale::DataSizeIecFormat);
|
||||
}
|
||||
|
||||
// The table wants a blank Speed cell when idle, not "0 B/s" — fmt::rate is for the status
|
||||
// bar, which does want the zero. Kept local for that one difference.
|
||||
QString humanRate(qint64 bps) {
|
||||
if (bps <= 0) {
|
||||
return QString();
|
||||
@@ -22,19 +19,6 @@ QString humanRate(qint64 bps) {
|
||||
QLocale().formattedDataSize(bps, 1, QLocale::DataSizeIecFormat));
|
||||
}
|
||||
|
||||
QString humanEta(qint64 secs) {
|
||||
if (secs < 0) {
|
||||
return QString();
|
||||
}
|
||||
const qint64 h = secs / 3600;
|
||||
const qint64 m = (secs % 3600) / 60;
|
||||
const qint64 s = secs % 60;
|
||||
return QStringLiteral("%1:%2:%3")
|
||||
.arg(h, 2, 10, QLatin1Char('0'))
|
||||
.arg(m, 2, 10, QLatin1Char('0'))
|
||||
.arg(s, 2, 10, QLatin1Char('0'));
|
||||
}
|
||||
|
||||
QString stateLabel(const QString &state) {
|
||||
static const QHash<QString, QString> kLabels = {
|
||||
{QStringLiteral("new"), DownloadTableModel::tr("New")},
|
||||
@@ -119,7 +103,7 @@ QVariant DownloadTableModel::data(const QModelIndex &index, int role) const {
|
||||
case ColQueue:
|
||||
return r.queuePosition >= 0 ? QString::number(r.queuePosition) : QString();
|
||||
case ColSize:
|
||||
return humanBytes(r.sizeBytes);
|
||||
return fmt::bytes(r.sizeBytes);
|
||||
case ColStatus:
|
||||
if (r.state == QLatin1String("downloading")) {
|
||||
const double p = progressOf(r);
|
||||
@@ -128,7 +112,7 @@ QVariant DownloadTableModel::data(const QModelIndex &index, int role) const {
|
||||
}
|
||||
return stateLabel(r.state);
|
||||
case ColTimeLeft:
|
||||
return r.state == QLatin1String("downloading") ? humanEta(r.etaSeconds) : QString();
|
||||
return r.state == QLatin1String("downloading") ? fmt::eta(r.etaSeconds) : QString();
|
||||
case ColSpeed:
|
||||
return r.state == QLatin1String("downloading") ? humanRate(r.speedBps) : QString();
|
||||
case ColLastTry:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Shared human-readable formatting. Lane GUI.
|
||||
//
|
||||
// Pulled out once three places (the table model, the main window status bar, the
|
||||
// progress dialog) all wanted the same "bytes / rate / eta" strings.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QLocale>
|
||||
#include <QString>
|
||||
|
||||
namespace velox::gui::fmt {
|
||||
|
||||
inline QString bytes(qint64 n) {
|
||||
if (n < 0) {
|
||||
return QStringLiteral("—");
|
||||
}
|
||||
return QLocale().formattedDataSize(n, 2, QLocale::DataSizeIecFormat);
|
||||
}
|
||||
|
||||
inline QString rate(qint64 bytesPerSec) {
|
||||
if (bytesPerSec <= 0) {
|
||||
return QCoreApplication::translate("velox::gui::fmt", "0 B/s");
|
||||
}
|
||||
return QCoreApplication::translate("velox::gui::fmt", "%1/s")
|
||||
.arg(QLocale().formattedDataSize(bytesPerSec, 1, QLocale::DataSizeIecFormat));
|
||||
}
|
||||
|
||||
/// HH:MM:SS, or empty for a negative (unknown) eta.
|
||||
inline QString eta(qint64 secs) {
|
||||
if (secs < 0) {
|
||||
return QString();
|
||||
}
|
||||
const qint64 h = secs / 3600;
|
||||
const qint64 m = (secs % 3600) / 60;
|
||||
const qint64 s = secs % 60;
|
||||
return QStringLiteral("%1:%2:%3")
|
||||
.arg(h, 2, 10, QLatin1Char('0'))
|
||||
.arg(m, 2, 10, QLatin1Char('0'))
|
||||
.arg(s, 2, 10, QLatin1Char('0'));
|
||||
}
|
||||
|
||||
} // namespace velox::gui::fmt
|
||||
@@ -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,44 @@ 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")
|
||||
|
||||
# tst_fileinfodialog — FileInfoDialog::buildSpec, the pure DownloadSpec builder.
|
||||
# Red when: an empty optional field starts getting sent instead of omitted, or the
|
||||
# queueId stops being dropped for a startMode other than "queue".
|
||||
add_executable(tst_fileinfodialog tst_fileinfodialog.cpp)
|
||||
target_compile_features(tst_fileinfodialog PRIVATE cxx_std_23)
|
||||
target_compile_options(tst_fileinfodialog PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(tst_fileinfodialog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
|
||||
add_test(NAME gui_fileinfodialog COMMAND tst_fileinfodialog)
|
||||
set_tests_properties(gui_fileinfodialog 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,66 @@
|
||||
// FileInfoDialog::buildSpec unit tests. Lane GUI.
|
||||
//
|
||||
// Pure function, no widgets involved — see the comment on the declaration. Red when an
|
||||
// optional field starts being sent empty instead of omitted, or queueId leaks into a spec
|
||||
// whose startMode isn't "queue".
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QtTest>
|
||||
|
||||
#include "dialogs/FileInfoDialog.hpp"
|
||||
|
||||
using velox::gui::FileInfoDialog;
|
||||
|
||||
class TstFileInfoDialog : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void requiredFieldsAlwaysPresent();
|
||||
void emptyOptionalFieldsAreOmitted();
|
||||
void fullSpecIncludesEveryField();
|
||||
void queueIdOnlySentForQueueStartMode();
|
||||
};
|
||||
|
||||
void TstFileInfoDialog::requiredFieldsAlwaysPresent() {
|
||||
const QJsonObject spec =
|
||||
FileInfoDialog::buildSpec("https://example.test/file.bin", "", "", "", "", 0, 0, "now", "");
|
||||
QCOMPARE(spec.value("url").toString(), QStringLiteral("https://example.test/file.bin"));
|
||||
QCOMPARE(spec.value("startMode").toString(), QStringLiteral("now"));
|
||||
}
|
||||
|
||||
void TstFileInfoDialog::emptyOptionalFieldsAreOmitted() {
|
||||
const QJsonObject spec =
|
||||
FileInfoDialog::buildSpec("https://example.test/file.bin", "", "", "", "", 0, 0, "now", "");
|
||||
QVERIFY(!spec.contains("filename"));
|
||||
QVERIFY(!spec.contains("saveDir"));
|
||||
QVERIFY(!spec.contains("categoryId"));
|
||||
QVERIFY(!spec.contains("description"));
|
||||
QVERIFY(!spec.contains("segments"));
|
||||
QVERIFY(!spec.contains("bufferBytes"));
|
||||
QVERIFY(!spec.contains("queueId"));
|
||||
}
|
||||
|
||||
void TstFileInfoDialog::fullSpecIncludesEveryField() {
|
||||
const QJsonObject spec =
|
||||
FileInfoDialog::buildSpec("https://example.test/file.bin", "file.bin", "/home/x/Downloads",
|
||||
"cat1", "a note", 8, 4 * 1024 * 1024, "later", "");
|
||||
QCOMPARE(spec.value("filename").toString(), QStringLiteral("file.bin"));
|
||||
QCOMPARE(spec.value("saveDir").toString(), QStringLiteral("/home/x/Downloads"));
|
||||
QCOMPARE(spec.value("categoryId").toString(), QStringLiteral("cat1"));
|
||||
QCOMPARE(spec.value("description").toString(), QStringLiteral("a note"));
|
||||
QCOMPARE(spec.value("segments").toInt(), 8);
|
||||
QCOMPARE(spec.value("bufferBytes").toDouble(), static_cast<double>(4 * 1024 * 1024));
|
||||
}
|
||||
|
||||
void TstFileInfoDialog::queueIdOnlySentForQueueStartMode() {
|
||||
const QJsonObject queued = FileInfoDialog::buildSpec("https://example.test/file.bin", "", "",
|
||||
"", "", 0, 0, "queue", "q1");
|
||||
QCOMPARE(queued.value("queueId").toString(), QStringLiteral("q1"));
|
||||
|
||||
const QJsonObject notQueued = FileInfoDialog::buildSpec("https://example.test/file.bin", "", "",
|
||||
"", "", 0, 0, "now", "q1");
|
||||
QVERIFY(!notQueued.contains("queueId"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TstFileInfoDialog)
|
||||
#include "tst_fileinfodialog.moc"
|
||||
@@ -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