diff --git a/gui/CMakeLists.txt b/gui/CMakeLists.txt index 9a52d47..aeddb77 100644 --- a/gui/CMakeLists.txt +++ b/gui/CMakeLists.txt @@ -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 ) diff --git a/gui/src/dialogs/AddUrlDialog.cpp b/gui/src/dialogs/AddUrlDialog.cpp new file mode 100644 index 0000000..89c9b38 --- /dev/null +++ b/gui/src/dialogs/AddUrlDialog.cpp @@ -0,0 +1,58 @@ +#include "dialogs/AddUrlDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +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 diff --git a/gui/src/dialogs/AddUrlDialog.hpp b/gui/src/dialogs/AddUrlDialog.hpp new file mode 100644 index 0000000..46c940c --- /dev/null +++ b/gui/src/dialogs/AddUrlDialog.hpp @@ -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 + +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 diff --git a/gui/src/dialogs/FileInfoDialog.cpp b/gui/src/dialogs/FileInfoDialog.cpp new file mode 100644 index 0000000..7ce8b35 --- /dev/null +++ b/gui/src/dialogs/FileInfoDialog.cpp @@ -0,0 +1,268 @@ +#include "dialogs/FileInfoDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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(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 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 diff --git a/gui/src/dialogs/FileInfoDialog.hpp b/gui/src/dialogs/FileInfoDialog.hpp new file mode 100644 index 0000000..da7a04f --- /dev/null +++ b/gui/src/dialogs/FileInfoDialog.hpp @@ -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 +#include +#include + +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 diff --git a/gui/src/dialogs/ProgressDialog.cpp b/gui/src/dialogs/ProgressDialog.cpp new file mode 100644 index 0000000..9cd25eb --- /dev/null +++ b/gui/src/dialogs/ProgressDialog.cpp @@ -0,0 +1,274 @@ +#include "dialogs/ProgressDialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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 segmentsFromDetail(const QJsonArray &detail) { + std::vector out; + out.reserve(static_cast(detail.size())); + for (const QJsonValue &v : detail) { + const QJsonObject s = v.toObject(); + SegmentInfo info; + info.index = static_cast(s.value("index").toDouble()); + info.downloadedBytes = static_cast(s.value("downloadedBytes").toDouble()); + const qint64 start = static_cast(s.value("startByte").toDouble()); + const qint64 end = static_cast(s.value("endByte").toDouble()); + info.totalBytes = end >= start ? (end - start + 1) : -1; + info.speedBps = static_cast(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 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(size.toDouble()) : -1; + downloadedBytes_ = static_cast(summary.value("downloadedBytes").toDouble()); + speedBps_ = static_cast(summary.value("speedBps").toDouble()); + const QJsonValue eta = summary.value("etaSeconds"); + etaSeconds_ = eta.isDouble() ? static_cast(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(downloadedBytes_) / static_cast(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(t.value("downloadedBytes").toDouble()); + speedBps_ = static_cast(t.value("speedBps").toDouble()); + const QJsonValue eta = t.value("etaSeconds"); + etaSeconds_ = eta.isDouble() ? static_cast(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(s.value("index").toDouble()); + for (auto &seg : segments_) { + if (seg.index == idx) { + seg.downloadedBytes = + static_cast(s.value("downloadedBytes").toDouble()); + seg.speedBps = static_cast(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 diff --git a/gui/src/dialogs/ProgressDialog.hpp b/gui/src/dialogs/ProgressDialog.hpp new file mode 100644 index 0000000..538afd0 --- /dev/null +++ b/gui/src/dialogs/ProgressDialog.hpp @@ -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 +#include +#include +#include + +#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 segments_; +}; + +} // namespace velox::gui diff --git a/gui/src/mainwindow/MainWindow.cpp b/gui/src/mainwindow/MainWindow.cpp index c36ebf4..4c3722c 100644 --- a/gui/src/mainwindow/MainWindow.cpp +++ b/gui/src/mainwindow/MainWindow.cpp @@ -22,22 +22,18 @@ #include #include +#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 { diff --git a/gui/src/mainwindow/MainWindow.hpp b/gui/src/mainwindow/MainWindow.hpp index edaf3b3..d175ad6 100644 --- a/gui/src/mainwindow/MainWindow.hpp +++ b/gui/src/mainwindow/MainWindow.hpp @@ -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 +#include #include +#include #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_; diff --git a/gui/src/models/DownloadTableModel.cpp b/gui/src/models/DownloadTableModel.cpp index 34ad12d..3fd14dc 100644 --- a/gui/src/models/DownloadTableModel.cpp +++ b/gui/src/models/DownloadTableModel.cpp @@ -4,16 +4,13 @@ #include +#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 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: diff --git a/gui/src/util/Format.hpp b/gui/src/util/Format.hpp new file mode 100644 index 0000000..999d694 --- /dev/null +++ b/gui/src/util/Format.hpp @@ -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 +#include +#include + +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 diff --git a/gui/tests/CMakeLists.txt b/gui/tests/CMakeLists.txt index cd7b75e..ce8f654 100644 --- a/gui/tests/CMakeLists.txt +++ b/gui/tests/CMakeLists.txt @@ -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. diff --git a/gui/tests/tst_fileinfodialog.cpp b/gui/tests/tst_fileinfodialog.cpp new file mode 100644 index 0000000..3e2e27d --- /dev/null +++ b/gui/tests/tst_fileinfodialog.cpp @@ -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 +#include + +#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(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"