gui: finish Add URL -> File Info -> Progress dialog flow

Wires up the three dialogs from build order step 5 (docs/03-gui-spec.md
§§2-3) and the MainWindow slots that were declared but never implemented:

- AddUrlDialog: clipboard prefill is the explicit path docs/06 R2 calls
  for (no passive monitoring, not advertised).
- FileInfoDialog: async download.probe never blocks the UI; ends by
  calling download.add itself (Now / Later / Add to Queue). buildSpec()
  is a pure static so the optional-field-omission logic is unit-testable
  without touching a widget.
- ProgressDialog: non-modal, WA_DeleteOnClose, driven by the taskProgress/
  taskStateChanged signals RpcClient already re-broadcasts; download.get
  seeds state once for a dialog opened mid-transfer. Hosts SegmentBarsWidget
  and SpeedGraphWidget.

MainWindow: openAddUrlDialog/openPropertiesForSelection/showTableContextMenu
now have bodies; category.list/queue.list responses are cached so File Info
can populate its category combo and queue menu without a second round trip.
The row context menu covers what already exists (Resume/Pause/Stop/Delete/
Properties) and deliberately leaves out Open/Open With/Move-Rename/
Redownload/Add to Queue — those need dialogs later build-order steps haven't
reached yet.

util/Format.hpp: pulled the bytes/rate/eta formatting out of MainWindow and
DownloadTableModel once the dialogs wanted the same strings a third time.

Verified end-to-end against a running mockd (category.list/queue.list,
download.probe, download.add, download.get, and live event.task.progress/
event.task.state) under ASan+UBSan: all three dialogs render correctly
against real fixture data and the flow runs clean with no leaks or
sanitizer reports.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
This commit is contained in:
2026-09-11 12:11:13 +04:00
co-authored by Claude Sonnet 5
parent 41bce91770
commit a71d904a1f
13 changed files with 989 additions and 38 deletions
+3
View File
@@ -26,6 +26,9 @@ add_library(velox-gui-lib STATIC
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
)
+58
View File
@@ -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
+30
View File
@@ -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
+268
View File
@@ -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
+66
View File
@@ -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
+274
View File
@@ -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 &params) {
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
+75
View File
@@ -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 &params);
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
+74 -13
View File
@@ -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 {
+14 -3
View File
@@ -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_;
+6 -22
View File
@@ -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:
+43
View File
@@ -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
+12
View File
@@ -70,6 +70,18 @@ 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.
+66
View File
@@ -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"