diff --git a/gui/CMakeLists.txt b/gui/CMakeLists.txt index ef7c314..aeddb77 100644 --- a/gui/CMakeLists.txt +++ b/gui/CMakeLists.txt @@ -24,6 +24,11 @@ add_library(velox-gui-lib STATIC src/models/DownloadTableModel.cpp src/models/DownloadFilterProxy.cpp src/widgets/ProgressDelegate.cpp + src/widgets/SegmentBarsWidget.cpp + src/widgets/SpeedGraphWidget.cpp + src/dialogs/AddUrlDialog.cpp + src/dialogs/FileInfoDialog.cpp + src/dialogs/ProgressDialog.cpp src/mainwindow/CategoryPanel.cpp src/mainwindow/MainWindow.cpp ) 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/src/widgets/SegmentBarsWidget.cpp b/gui/src/widgets/SegmentBarsWidget.cpp new file mode 100644 index 0000000..6865c83 --- /dev/null +++ b/gui/src/widgets/SegmentBarsWidget.cpp @@ -0,0 +1,79 @@ +#include "widgets/SegmentBarsWidget.hpp" + +#include + +#include +#include +#include +#include +#include + +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(rows_.size()) <= i) { + const int idx = static_cast(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(i)]; +} + +void SegmentBarsWidget::setSegments(const std::vector &segments) { + for (std::size_t i = 0; i < segments.size(); ++i) { + Row &row = rowFor(static_cast(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(seg.downloadedBytes) / static_cast(seg.totalBytes), 0.0, + 1.0); + row.bar->setRange(0, 10000); + row.bar->setValue(static_cast(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 diff --git a/gui/src/widgets/SegmentBarsWidget.hpp b/gui/src/widgets/SegmentBarsWidget.hpp new file mode 100644 index 0000000..2dc3dfa --- /dev/null +++ b/gui/src/widgets/SegmentBarsWidget.hpp @@ -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 +#include + +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 &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 rows_; +}; + +} // namespace velox::gui diff --git a/gui/src/widgets/SpeedGraphWidget.cpp b/gui/src/widgets/SpeedGraphWidget.cpp new file mode 100644 index 0000000..555d36c --- /dev/null +++ b/gui/src/widgets/SpeedGraphWidget.cpp @@ -0,0 +1,96 @@ +#include "widgets/SpeedGraphWidget.hpp" + +#include + +#include +#include + +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(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(i)]); + } + + const double w = width(); + const double h = height(); + const double stepX = w / static_cast(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(v) / static_cast(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((oldest + i) % kWindowSeconds)]; + // Right-align: the newest sample sits at the right edge. + const double x = w - static_cast(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((oldest + i) % kWindowSeconds)]; + const double x = w - static_cast(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 diff --git a/gui/src/widgets/SpeedGraphWidget.hpp b/gui/src/widgets/SpeedGraphWidget.hpp new file mode 100644 index 0000000..468ce69 --- /dev/null +++ b/gui/src/widgets/SpeedGraphWidget.hpp @@ -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 + +#include +#include +#include + +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 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 diff --git a/gui/tests/CMakeLists.txt b/gui/tests/CMakeLists.txt index 3428ead..ce8f654 100644 --- a/gui/tests/CMakeLists.txt +++ b/gui/tests/CMakeLists.txt @@ -44,6 +44,44 @@ set_tests_properties(gui_rtl PROPERTIES LABELS "gui" ENVIRONMENT "QT_QPA_PLATFORM=offscreen") +# tst_segmentbarswidget — the per-connection bar grid. +# Red when: a determinate segment stops rendering its percentage, an unknown-length +# segment stops falling back to the indeterminate range, a shrinking segment set +# destroys rows instead of hiding them, or rows start getting rebuilt per tick. +add_executable(tst_segmentbarswidget tst_segmentbarswidget.cpp) +target_compile_features(tst_segmentbarswidget PRIVATE cxx_std_23) +target_compile_options(tst_segmentbarswidget PRIVATE -Wall -Wextra -Wpedantic -Werror) +target_link_libraries(tst_segmentbarswidget PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test) +add_test(NAME gui_segmentbarswidget COMMAND tst_segmentbarswidget) +set_tests_properties(gui_segmentbarswidget PROPERTIES + LABELS "gui" + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + +# tst_speedgraphwidget — the 60 s rolling speed graph. +# Red when: addSample stops throttling bursts to 1 Hz, the bucket count stops +# incrementing on genuinely separated samples, clear() leaves stale state, or painting +# crashes before two samples exist. +add_executable(tst_speedgraphwidget tst_speedgraphwidget.cpp) +target_compile_features(tst_speedgraphwidget PRIVATE cxx_std_23) +target_compile_options(tst_speedgraphwidget PRIVATE -Wall -Wextra -Wpedantic -Werror) +target_link_libraries(tst_speedgraphwidget PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test) +add_test(NAME gui_speedgraphwidget COMMAND tst_speedgraphwidget) +set_tests_properties(gui_speedgraphwidget PROPERTIES + LABELS "gui" + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + +# tst_fileinfodialog — FileInfoDialog::buildSpec, the pure DownloadSpec builder. +# Red when: an empty optional field starts getting sent instead of omitted, or the +# queueId stops being dropped for a startMode other than "queue". +add_executable(tst_fileinfodialog tst_fileinfodialog.cpp) +target_compile_features(tst_fileinfodialog PRIVATE cxx_std_23) +target_compile_options(tst_fileinfodialog PRIVATE -Wall -Wextra -Wpedantic -Werror) +target_link_libraries(tst_fileinfodialog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test) +add_test(NAME gui_fileinfodialog COMMAND tst_fileinfodialog) +set_tests_properties(gui_fileinfodialog PROPERTIES + LABELS "gui" + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + # gui_no_download_logic — CLAUDE.md §3 as an executable check, not a hope. # Red when: a download-logic token (curl, raw pwrite, sqlite, QSqlDatabase) appears # under gui/src. grep exits 0 only when it finds a match, so a hit fails the test. 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" diff --git a/gui/tests/tst_segmentbarswidget.cpp b/gui/tests/tst_segmentbarswidget.cpp new file mode 100644 index 0000000..c4c66d6 --- /dev/null +++ b/gui/tests/tst_segmentbarswidget.cpp @@ -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 +#include + +#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(); + 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(); + 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(); + 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(); + 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(); + QVERIFY(barFirst != nullptr); + + s.downloadedBytes = 40; + w.setSegments({s}); + auto *barSecond = w.findChild(); + QCOMPARE(barFirst, barSecond); // same widget instance, just repainted +} + +QTEST_MAIN(TstSegmentBarsWidget) +#include "tst_segmentbarswidget.moc" diff --git a/gui/tests/tst_speedgraphwidget.cpp b/gui/tests/tst_speedgraphwidget.cpp new file mode 100644 index 0000000..1042f5c --- /dev/null +++ b/gui/tests/tst_speedgraphwidget.cpp @@ -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 + +#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"