merge: lane/gui
This commit is contained in:
@@ -24,6 +24,11 @@ add_library(velox-gui-lib STATIC
|
|||||||
src/models/DownloadTableModel.cpp
|
src/models/DownloadTableModel.cpp
|
||||||
src/models/DownloadFilterProxy.cpp
|
src/models/DownloadFilterProxy.cpp
|
||||||
src/widgets/ProgressDelegate.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/CategoryPanel.cpp
|
||||||
src/mainwindow/MainWindow.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 <QVBoxLayout>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "dialogs/AddUrlDialog.hpp"
|
||||||
|
#include "dialogs/FileInfoDialog.hpp"
|
||||||
|
#include "dialogs/ProgressDialog.hpp"
|
||||||
#include "mainwindow/CategoryPanel.hpp"
|
#include "mainwindow/CategoryPanel.hpp"
|
||||||
#include "models/DownloadTableModel.hpp"
|
#include "models/DownloadTableModel.hpp"
|
||||||
#include "rpc/RpcClient.hpp"
|
#include "rpc/RpcClient.hpp"
|
||||||
|
#include "util/Format.hpp"
|
||||||
#include "widgets/ProgressDelegate.hpp"
|
#include "widgets/ProgressDelegate.hpp"
|
||||||
|
|
||||||
namespace velox::gui {
|
namespace velox::gui {
|
||||||
namespace {
|
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) {
|
QString dotStyle(const QString &colour) {
|
||||||
return QStringLiteral("color: %1; font-size: 14px;").arg(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_->setSelectionMode(QAbstractItemView::ExtendedSelection);
|
||||||
view_->setSortingEnabled(true);
|
view_->setSortingEnabled(true);
|
||||||
view_->setItemDelegateForColumn(DownloadTableModel::ColStatus, new ProgressDelegate(this));
|
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()->setSectionsMovable(true);
|
||||||
view_->header()->setStretchLastSection(true);
|
view_->header()->setStretchLastSection(true);
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ MainWindow::~MainWindow() = default;
|
|||||||
void MainWindow::buildActions() {
|
void MainWindow::buildActions() {
|
||||||
actAddUrl_ = new QAction(tr("Add &URL…"), this);
|
actAddUrl_ = new QAction(tr("Add &URL…"), this);
|
||||||
actAddUrl_->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_N));
|
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_ = new QAction(tr("&Resume"), this);
|
||||||
actResume_->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
|
actResume_->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
|
||||||
@@ -163,6 +163,9 @@ void MainWindow::buildActions() {
|
|||||||
|
|
||||||
actPauseAll_ = new QAction(tr("Pause All"), this);
|
actPauseAll_ = new QAction(tr("Pause All"), this);
|
||||||
connect(actPauseAll_, &QAction::triggered, this, &MainWindow::pauseAll);
|
connect(actPauseAll_, &QAction::triggered, this, &MainWindow::pauseAll);
|
||||||
|
|
||||||
|
actProperties_ = new QAction(tr("&Properties…"), this);
|
||||||
|
connect(actProperties_, &QAction::triggered, this, &MainWindow::openPropertiesForSelection);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::buildMenus() {
|
void MainWindow::buildMenus() {
|
||||||
@@ -174,6 +177,7 @@ void MainWindow::buildMenus() {
|
|||||||
tasks->addAction(actStop_);
|
tasks->addAction(actStop_);
|
||||||
tasks->addSeparator();
|
tasks->addSeparator();
|
||||||
tasks->addAction(actRemove_);
|
tasks->addAction(actRemove_);
|
||||||
|
tasks->addAction(actProperties_);
|
||||||
tasks->addSeparator();
|
tasks->addSeparator();
|
||||||
tasks->addAction(tr("E&xit"), QKeySequence::Quit, this, &QWidget::close);
|
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; "
|
: tr("The Velox service is not running. Downloads are unaffected; "
|
||||||
"this list is read-only until it returns."));
|
"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);
|
a->setEnabled(online);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,16 +254,72 @@ void MainWindow::onConnectionState(rpc::ConnectionState state) {
|
|||||||
void MainWindow::fetchTree() {
|
void MainWindow::fetchTree() {
|
||||||
client_->call(QStringLiteral("category.list"), {}, [this](const rpc::RpcReply &reply) {
|
client_->call(QStringLiteral("category.list"), {}, [this](const rpc::RpcReply &reply) {
|
||||||
if (reply.ok()) {
|
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) {
|
client_->call(QStringLiteral("queue.list"), {}, [this](const rpc::RpcReply &reply) {
|
||||||
if (reply.ok()) {
|
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) {
|
void MainWindow::onFilterChanged(const TaskSelection &selection) {
|
||||||
proxy_->setSelection(selection);
|
proxy_->setSelection(selection);
|
||||||
// TODO(gui): also push proxy_->asTaskFilter() to a fresh download.list once the daemon
|
// TODO(gui): also push proxy_->asTaskFilter() to a fresh download.list once the daemon
|
||||||
@@ -315,7 +376,7 @@ void MainWindow::refreshCounts() {
|
|||||||
.arg(shown)
|
.arg(shown)
|
||||||
.arg(total)
|
.arg(total)
|
||||||
.arg(active)
|
.arg(active)
|
||||||
.arg(humanRate(globalDownBps_)));
|
.arg(fmt::rate(globalDownBps_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
QStringList MainWindow::selectedTaskIds() const {
|
QStringList MainWindow::selectedTaskIds() const {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
// The main window. Lane GUI.
|
// The main window. Lane GUI.
|
||||||
//
|
//
|
||||||
// M1 slice: dialogs and the tray still come later. This is the menus + toolbar + splitter
|
// M1 slice: the tray and the Options/Scheduler/Speed-Limiter/Batch/Grabber dialogs still
|
||||||
// (category tree | table), the table driven by mockd, a status-bar connection dot, an
|
// come later. This is the menus + toolbar + splitter (category tree | table), the table
|
||||||
// offline banner instead of a modal, and the actions that need no dialog.
|
// 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
|
#pragma once
|
||||||
|
|
||||||
#include <QHash>
|
#include <QHash>
|
||||||
|
#include <QJsonArray>
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
|
#include <QPoint>
|
||||||
|
|
||||||
#include "models/DownloadFilterProxy.hpp"
|
#include "models/DownloadFilterProxy.hpp"
|
||||||
#include "rpc/Protocol.hpp"
|
#include "rpc/Protocol.hpp"
|
||||||
@@ -49,6 +52,10 @@ class MainWindow : public QMainWindow {
|
|||||||
void resumeAll();
|
void resumeAll();
|
||||||
void pauseAll();
|
void pauseAll();
|
||||||
|
|
||||||
|
void openAddUrlDialog();
|
||||||
|
void openPropertiesForSelection();
|
||||||
|
void showTableContextMenu(const QPoint &pos);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void buildActions();
|
void buildActions();
|
||||||
void buildMenus();
|
void buildMenus();
|
||||||
@@ -73,6 +80,10 @@ class MainWindow : public QMainWindow {
|
|||||||
QAction *actRemove_ = nullptr;
|
QAction *actRemove_ = nullptr;
|
||||||
QAction *actResumeAll_ = nullptr;
|
QAction *actResumeAll_ = nullptr;
|
||||||
QAction *actPauseAll_ = nullptr;
|
QAction *actPauseAll_ = nullptr;
|
||||||
|
QAction *actProperties_ = nullptr;
|
||||||
|
|
||||||
|
QJsonArray categoriesCache_;
|
||||||
|
QJsonArray queuesCache_;
|
||||||
|
|
||||||
QLabel *connDot_;
|
QLabel *connDot_;
|
||||||
QLabel *connText_;
|
QLabel *connText_;
|
||||||
|
|||||||
@@ -4,16 +4,13 @@
|
|||||||
|
|
||||||
#include <QLocale>
|
#include <QLocale>
|
||||||
|
|
||||||
|
#include "util/Format.hpp"
|
||||||
|
|
||||||
namespace velox::gui {
|
namespace velox::gui {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
QString humanBytes(qint64 n) {
|
// The table wants a blank Speed cell when idle, not "0 B/s" — fmt::rate is for the status
|
||||||
if (n < 0) {
|
// bar, which does want the zero. Kept local for that one difference.
|
||||||
return QStringLiteral("—");
|
|
||||||
}
|
|
||||||
return QLocale().formattedDataSize(n, 2, QLocale::DataSizeIecFormat);
|
|
||||||
}
|
|
||||||
|
|
||||||
QString humanRate(qint64 bps) {
|
QString humanRate(qint64 bps) {
|
||||||
if (bps <= 0) {
|
if (bps <= 0) {
|
||||||
return QString();
|
return QString();
|
||||||
@@ -22,19 +19,6 @@ QString humanRate(qint64 bps) {
|
|||||||
QLocale().formattedDataSize(bps, 1, QLocale::DataSizeIecFormat));
|
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) {
|
QString stateLabel(const QString &state) {
|
||||||
static const QHash<QString, QString> kLabels = {
|
static const QHash<QString, QString> kLabels = {
|
||||||
{QStringLiteral("new"), DownloadTableModel::tr("New")},
|
{QStringLiteral("new"), DownloadTableModel::tr("New")},
|
||||||
@@ -119,7 +103,7 @@ QVariant DownloadTableModel::data(const QModelIndex &index, int role) const {
|
|||||||
case ColQueue:
|
case ColQueue:
|
||||||
return r.queuePosition >= 0 ? QString::number(r.queuePosition) : QString();
|
return r.queuePosition >= 0 ? QString::number(r.queuePosition) : QString();
|
||||||
case ColSize:
|
case ColSize:
|
||||||
return humanBytes(r.sizeBytes);
|
return fmt::bytes(r.sizeBytes);
|
||||||
case ColStatus:
|
case ColStatus:
|
||||||
if (r.state == QLatin1String("downloading")) {
|
if (r.state == QLatin1String("downloading")) {
|
||||||
const double p = progressOf(r);
|
const double p = progressOf(r);
|
||||||
@@ -128,7 +112,7 @@ QVariant DownloadTableModel::data(const QModelIndex &index, int role) const {
|
|||||||
}
|
}
|
||||||
return stateLabel(r.state);
|
return stateLabel(r.state);
|
||||||
case ColTimeLeft:
|
case ColTimeLeft:
|
||||||
return r.state == QLatin1String("downloading") ? humanEta(r.etaSeconds) : QString();
|
return r.state == QLatin1String("downloading") ? fmt::eta(r.etaSeconds) : QString();
|
||||||
case ColSpeed:
|
case ColSpeed:
|
||||||
return r.state == QLatin1String("downloading") ? humanRate(r.speedBps) : QString();
|
return r.state == QLatin1String("downloading") ? humanRate(r.speedBps) : QString();
|
||||||
case ColLastTry:
|
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"
|
LABELS "gui"
|
||||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
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.
|
# 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
|
# 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.
|
# 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