gui: Options, Scheduler, Speed Limiter, Batch, Grabber, and the tray icon
Continues the build order past the Add-URL/File-Info/Progress dialog flow.
- OptionsDialog: General/Save To/Connection/Downloads/Proxy/Sounds tabs, every
control bound to a real settings.* key (contracts/schema/types/Settings.
schema.json). The spec's File Types and Site Logins tabs have no settings.*
backing (categories go through category.upsert, credentials through the
Secret Service) so they don't exist here — a tab either binds to a real key
or isn't shipped. diffChanged() sends only what actually changed, matching
settings.set's "changed[] names exactly what took effect" contract.
- SchedulerDialog: per-queue schedule (schedule.get/set) plus maxConcurrent/
onComplete (queue.upsert), Start Now/Stop. Queue.schema.json already carries
the schedule so queue.list alone seeds the window.
- SpeedLimiterDialog: the live global limiter (limiter.get/set) — a different
thing from Options' downloads.speedLimit* default. buildParams() enforces
the schema's "0 with enabled true must not be offered".
- BatchDialog: clipboard-blob and {start..end}-wildcard tabs sharing one
category/queue/start-mode footer into download.addBatch.
- GrabberWizard: 4-step QWizard (project label -> start URL/depth/filters ->
file-type filter -> review), grabber.start feeding a poll+event.grabber.
progress-driven review page, Finish = grabber.harvest for the checked files.
- TrayIcon: active-count tooltip, Show/Add URL/Pause All/Resume All/Speed
Limiter submenu/Quit. Quit only closes the GUI — there is no RPC to stop
veloxd itself, filed as a new gap in daemon-requests-m1.md. MainWindow now
also hides to tray instead of closing when general.minimizeToTray is set.
Every dialog's non-widget logic (diffChanged, buildSchedule, buildParams,
parseUrlBlob/expandWildcard/buildAddBatchParams, buildFileTypes/
buildStartParams/buildHarvestParams) is a static pure function with its own
test, same shape as FileInfoDialog::buildSpec from the previous round.
Verified end-to-end against a running mockd under ASan+UBSan: all five
surfaces render real data (settings.get values, queue.list's two seeded
queues, limiter.get, a live grabber.start/status crawl returning 3 files) with
no sanitizer reports. gui-check (non-ASan) and dev (ASan+UBSan) presets both
build the whole repo clean; all gui-labeled ctest targets pass.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
This commit is contained in:
@@ -29,6 +29,12 @@ add_library(velox-gui-lib STATIC
|
||||
src/dialogs/AddUrlDialog.cpp
|
||||
src/dialogs/FileInfoDialog.cpp
|
||||
src/dialogs/ProgressDialog.cpp
|
||||
src/dialogs/OptionsDialog.cpp
|
||||
src/dialogs/SchedulerDialog.cpp
|
||||
src/dialogs/SpeedLimiterDialog.cpp
|
||||
src/dialogs/BatchDialog.cpp
|
||||
src/dialogs/GrabberWizard.cpp
|
||||
src/tray/TrayIcon.cpp
|
||||
src/mainwindow/CategoryPanel.cpp
|
||||
src/mainwindow/MainWindow.cpp
|
||||
)
|
||||
|
||||
@@ -37,3 +37,20 @@ as a reference for what "no size" currently looks like end to end.
|
||||
Not urgent — mockd always supplies `sizeBytes`, so this doesn't block M1 GUI work — but
|
||||
worth knowing before the M1 GUI↔real-daemon integration pass, since the percentage bar is
|
||||
the headline visual of the main table.
|
||||
|
||||
## No method exists to stop `veloxd` itself
|
||||
|
||||
`docs/03-gui-spec.md` §5 describes the tray's Quit as asking "whether to also stop the
|
||||
daemon". `contracts/schema/methods/` has nothing for it — no `daemon.stop`/`daemon.shutdown`,
|
||||
and `Queue.onComplete`'s `"shutdown"` value is a *system* shutdown via
|
||||
`org.freedesktop.login1`, a different thing entirely. This is a schema read, not something
|
||||
that needed a live daemon to confirm — the method list is exhaustive and checkable
|
||||
directly.
|
||||
|
||||
**Effect on the GUI:** `TrayIcon`'s Quit is implemented as "quit the GUI only; downloads
|
||||
continue under the Velox service" — accurate today, since nothing else is possible, but it
|
||||
means the spec's described behaviour (offer to also stop the service) has no RPC to build
|
||||
on. Not urgent for M1 — CLI/systemd already stop `veloxd` outside the GUI — but worth a
|
||||
`contracts/`-only PR (new privileged UDS-only method, `x-deadlineMs` generous enough to
|
||||
cover in-flight transfers being paused first) before that tray menu item claims to do more
|
||||
than it does.
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
#include "dialogs/BatchDialog.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QClipboard>
|
||||
#include <QComboBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QGuiApplication>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QListWidget>
|
||||
#include <QPlainTextEdit>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QTabWidget>
|
||||
#include <QUrl>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
constexpr int kMaxBatchItems = 5000; // download.addBatch's items.maxItems
|
||||
|
||||
bool looksLikeUrl(const QString &s) {
|
||||
const QUrl u(s);
|
||||
return u.isValid() && !u.host().isEmpty() &&
|
||||
(u.scheme() == QLatin1String("http") || u.scheme() == QLatin1String("https"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QStringList BatchDialog::parseUrlBlob(const QString &text) {
|
||||
QStringList out;
|
||||
QSet<QString> seen;
|
||||
for (const QString &rawLine : text.split(QLatin1Char('\n'))) {
|
||||
const QString line = rawLine.trimmed();
|
||||
if (line.isEmpty() || !looksLikeUrl(line) || seen.contains(line)) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(line);
|
||||
out << line;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
QStringList BatchDialog::expandWildcard(const QString &pattern) {
|
||||
static const QRegularExpression kRange(QStringLiteral(R"(\{(\d+)\.\.(\d+)\})"));
|
||||
auto it = kRange.globalMatch(pattern);
|
||||
QRegularExpressionMatch first;
|
||||
int count = 0;
|
||||
while (it.hasNext()) {
|
||||
const QRegularExpressionMatch m = it.next();
|
||||
if (count == 0) {
|
||||
first = m;
|
||||
}
|
||||
++count;
|
||||
}
|
||||
if (count == 0) {
|
||||
return looksLikeUrl(pattern) ? QStringList{pattern} : QStringList{};
|
||||
}
|
||||
if (count > 1) {
|
||||
return {}; // more than one {a..b} token: ambiguous, refuse rather than guess
|
||||
}
|
||||
|
||||
const QString startStr = first.captured(1);
|
||||
const QString endStr = first.captured(2);
|
||||
const int start = startStr.toInt();
|
||||
const int end = endStr.toInt();
|
||||
if (end < start) {
|
||||
return {};
|
||||
}
|
||||
const bool zeroPad =
|
||||
startStr.startsWith(QLatin1Char('0')) || endStr.startsWith(QLatin1Char('0'));
|
||||
const int width = zeroPad ? std::max(startStr.size(), endStr.size()) : 0;
|
||||
|
||||
QStringList out;
|
||||
for (int i = start; i <= end && out.size() < kMaxBatchItems; ++i) {
|
||||
const QString num =
|
||||
zeroPad ? QStringLiteral("%1").arg(i, width, 10, QLatin1Char('0')) : QString::number(i);
|
||||
QString url = pattern;
|
||||
url.replace(first.capturedStart(), first.capturedLength(), num);
|
||||
if (looksLikeUrl(url)) {
|
||||
out << url;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
QJsonObject BatchDialog::buildAddBatchParams(const QStringList &urls, const QString &categoryId,
|
||||
const QString &queueId, const QString &startMode) {
|
||||
QJsonArray items;
|
||||
for (const QString &url : urls) {
|
||||
items.append(QJsonObject{{"url", url}});
|
||||
}
|
||||
QJsonObject defaults{{"startMode", startMode}};
|
||||
if (!categoryId.isEmpty()) {
|
||||
defaults["categoryId"] = categoryId;
|
||||
}
|
||||
if (startMode == QLatin1String("queue") && !queueId.isEmpty()) {
|
||||
defaults["queueId"] = queueId;
|
||||
}
|
||||
return QJsonObject{{"items", items}, {"defaults", defaults}};
|
||||
}
|
||||
|
||||
BatchDialog::BatchDialog(rpc::RpcClient *client, QJsonArray categories, QJsonArray queues,
|
||||
QWidget *parent)
|
||||
: QDialog(parent),
|
||||
client_(client),
|
||||
tabs_(new QTabWidget(this)),
|
||||
clipboardText_(new QPlainTextEdit(this)),
|
||||
clipboardPreview_(new QListWidget(this)),
|
||||
wildcardPattern_(new QLineEdit(this)),
|
||||
wildcardPreview_(new QListWidget(this)),
|
||||
categoryCombo_(new QComboBox(this)),
|
||||
startModeCombo_(new QComboBox(this)),
|
||||
queueCombo_(new QComboBox(this)),
|
||||
countLabel_(new QLabel(this)),
|
||||
errorLabel_(new QLabel(this)),
|
||||
addButton_(nullptr) {
|
||||
setWindowTitle(tr("Batch Download"));
|
||||
resize(560, 520);
|
||||
|
||||
auto *clipboardPage = new QWidget(this);
|
||||
clipboardText_->setPlaceholderText(tr("Paste a list of URLs, one per line…"));
|
||||
const QString clipboard = QGuiApplication::clipboard()->text();
|
||||
if (!parseUrlBlob(clipboard).isEmpty()) {
|
||||
clipboardText_->setPlainText(clipboard);
|
||||
}
|
||||
connect(clipboardText_, &QPlainTextEdit::textChanged, this,
|
||||
&BatchDialog::updateClipboardPreview);
|
||||
auto *clipboardLayout = new QVBoxLayout(clipboardPage);
|
||||
clipboardLayout->addWidget(new QLabel(tr("URLs:"), clipboardPage));
|
||||
clipboardLayout->addWidget(clipboardText_, 1);
|
||||
clipboardLayout->addWidget(new QLabel(tr("Will add:"), clipboardPage));
|
||||
clipboardLayout->addWidget(clipboardPreview_, 1);
|
||||
tabs_->addTab(clipboardPage, tr("From Clipboard"));
|
||||
|
||||
auto *wildcardPage = new QWidget(this);
|
||||
wildcardPattern_->setPlaceholderText(
|
||||
tr("http://host/path/img{1..50}.jpg — one {start..end} range"));
|
||||
connect(wildcardPattern_, &QLineEdit::textChanged, this, &BatchDialog::updateWildcardPreview);
|
||||
auto *wildcardLayout = new QVBoxLayout(wildcardPage);
|
||||
wildcardLayout->addWidget(new QLabel(tr("Pattern:"), wildcardPage));
|
||||
wildcardLayout->addWidget(wildcardPattern_);
|
||||
wildcardLayout->addWidget(new QLabel(tr("Will add:"), wildcardPage));
|
||||
wildcardLayout->addWidget(wildcardPreview_, 1);
|
||||
tabs_->addTab(wildcardPage, tr("With Wildcards"));
|
||||
|
||||
populateCategories(categories);
|
||||
|
||||
startModeCombo_->addItem(tr("Download Now"), QStringLiteral("now"));
|
||||
startModeCombo_->addItem(tr("Download Later"), QStringLiteral("later"));
|
||||
startModeCombo_->addItem(tr("Add to Queue"), QStringLiteral("queue"));
|
||||
populateQueues(queues);
|
||||
connect(startModeCombo_, &QComboBox::currentIndexChanged, this, [this] {
|
||||
queueCombo_->setEnabled(startModeCombo_->currentData().toString() ==
|
||||
QLatin1String("queue"));
|
||||
});
|
||||
queueCombo_->setEnabled(false);
|
||||
|
||||
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
||||
errorLabel_->setWordWrap(true);
|
||||
errorLabel_->hide();
|
||||
|
||||
auto *footer = new QFormLayout;
|
||||
footer->addRow(tr("Category:"), categoryCombo_);
|
||||
footer->addRow(tr("Start mode:"), startModeCombo_);
|
||||
footer->addRow(tr("Queue:"), queueCombo_);
|
||||
footer->addRow(countLabel_);
|
||||
|
||||
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Cancel, this);
|
||||
addButton_ = buttons->addButton(tr("Add"), QDialogButtonBox::AcceptRole);
|
||||
connect(addButton_, &QPushButton::clicked, this, &BatchDialog::submit);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addWidget(tabs_, 1);
|
||||
layout->addLayout(footer);
|
||||
layout->addWidget(errorLabel_);
|
||||
layout->addWidget(buttons);
|
||||
|
||||
connect(tabs_, &QTabWidget::currentChanged, this, [this] {
|
||||
updateClipboardPreview();
|
||||
updateWildcardPreview();
|
||||
});
|
||||
updateClipboardPreview();
|
||||
updateWildcardPreview();
|
||||
}
|
||||
|
||||
void BatchDialog::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 BatchDialog::populateQueues(const QJsonArray &queues) {
|
||||
for (const QJsonValue &v : queues) {
|
||||
const QJsonObject q = v.toObject();
|
||||
queueCombo_->addItem(q.value("name").toString(), q.value("queueId").toString());
|
||||
}
|
||||
if (queues.isEmpty()) {
|
||||
const int queueIdx = startModeCombo_->findData(QStringLiteral("queue"));
|
||||
if (queueIdx >= 0) {
|
||||
startModeCombo_->removeItem(queueIdx); // nothing to add it to
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QStringList BatchDialog::activeUrls() const {
|
||||
return tabs_->currentIndex() == 0 ? parseUrlBlob(clipboardText_->toPlainText())
|
||||
: expandWildcard(wildcardPattern_->text().trimmed());
|
||||
}
|
||||
|
||||
void BatchDialog::updateClipboardPreview() {
|
||||
const QStringList urls = parseUrlBlob(clipboardText_->toPlainText());
|
||||
clipboardPreview_->clear();
|
||||
clipboardPreview_->addItems(urls);
|
||||
if (tabs_->currentIndex() == 0) {
|
||||
countLabel_->setText(tr("%n URL(s)", "", urls.size()));
|
||||
addButton_->setEnabled(!urls.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
void BatchDialog::updateWildcardPreview() {
|
||||
const QStringList urls = expandWildcard(wildcardPattern_->text().trimmed());
|
||||
wildcardPreview_->clear();
|
||||
wildcardPreview_->addItems(urls);
|
||||
if (tabs_->currentIndex() == 1) {
|
||||
countLabel_->setText(tr("%n URL(s)", "", urls.size()));
|
||||
addButton_->setEnabled(!urls.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
void BatchDialog::submit() {
|
||||
const QStringList urls = activeUrls();
|
||||
if (urls.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
const QJsonObject params = buildAddBatchParams(urls, categoryCombo_->currentData().toString(),
|
||||
queueCombo_->currentData().toString(),
|
||||
startModeCombo_->currentData().toString());
|
||||
addButton_->setEnabled(false);
|
||||
QPointer<BatchDialog> self(this);
|
||||
client_->call(QString::fromLatin1(rpc::method::kDownloadAddBatch), params,
|
||||
[self](const rpc::RpcReply &reply) {
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
self->addButton_->setEnabled(true);
|
||||
if (!reply.ok()) {
|
||||
self->errorLabel_->setText(
|
||||
tr("Could not add the batch: %1").arg(reply.error.message));
|
||||
self->errorLabel_->show();
|
||||
return;
|
||||
}
|
||||
const QJsonArray failed = reply.result.toObject().value("failed").toArray();
|
||||
if (failed.isEmpty()) {
|
||||
self->accept();
|
||||
return;
|
||||
}
|
||||
self->errorLabel_->setText(
|
||||
tr("%n item(s) could not be added; the rest were.", "", failed.size()));
|
||||
self->errorLabel_->show();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,76 @@
|
||||
// Batch download dialog. Lane GUI.
|
||||
//
|
||||
// docs/03-gui-spec.md §5 describes this as two windows ("from clipboard" and "with
|
||||
// wildcards"); they share everything but how the URL list is produced, so this is one
|
||||
// dialog with a tab per source and a common category/queue/start-mode footer feeding a
|
||||
// single download.addBatch call.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
class QComboBox;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QListWidget;
|
||||
class QPlainTextEdit;
|
||||
class QPushButton;
|
||||
class QTabWidget;
|
||||
|
||||
namespace velox::gui {
|
||||
namespace rpc {
|
||||
class RpcClient;
|
||||
} // namespace rpc
|
||||
|
||||
class BatchDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
BatchDialog(rpc::RpcClient *client, QJsonArray categories, QJsonArray queues,
|
||||
QWidget *parent = nullptr);
|
||||
|
||||
/// Pure: one URL per non-blank line, trimmed, deduplicated (first occurrence wins,
|
||||
/// order preserved), kept only when it parses as an absolute http/https URL.
|
||||
static QStringList parseUrlBlob(const QString &text);
|
||||
|
||||
/// Pure: expand a single "{start..end}" numeric range in `pattern` (the wildcard
|
||||
/// dialog's `img{1..50}.jpg` example). Zero-padded when either bound has a leading
|
||||
/// zero, so "{01..10}" produces "01".."10". No range found -> the pattern itself, if it
|
||||
/// looks like an absolute URL; a malformed range (end < start, or more than one range)
|
||||
/// -> empty. Capped at download.addBatch's 5000-item limit.
|
||||
static QStringList expandWildcard(const QString &pattern);
|
||||
|
||||
/// Pure: the download.addBatch params for this URL list plus shared defaults. Only
|
||||
/// non-empty optional fields are set; queueId only when startMode is "queue".
|
||||
static QJsonObject buildAddBatchParams(const QStringList &urls, const QString &categoryId,
|
||||
const QString &queueId, const QString &startMode);
|
||||
|
||||
private slots:
|
||||
void updateClipboardPreview();
|
||||
void updateWildcardPreview();
|
||||
void submit();
|
||||
|
||||
private:
|
||||
void populateCategories(const QJsonArray &categories);
|
||||
void populateQueues(const QJsonArray &queues);
|
||||
QStringList activeUrls() const;
|
||||
|
||||
rpc::RpcClient *client_;
|
||||
|
||||
QTabWidget *tabs_;
|
||||
QPlainTextEdit *clipboardText_;
|
||||
QListWidget *clipboardPreview_;
|
||||
QLineEdit *wildcardPattern_;
|
||||
QListWidget *wildcardPreview_;
|
||||
|
||||
QComboBox *categoryCombo_;
|
||||
QComboBox *startModeCombo_;
|
||||
QComboBox *queueCombo_;
|
||||
QLabel *countLabel_;
|
||||
QLabel *errorLabel_;
|
||||
QPushButton *addButton_;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,443 @@
|
||||
#include "dialogs/GrabberWizard.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include <QAbstractItemView>
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QJsonArray>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QLocale>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QRegularExpression>
|
||||
#include <QSpinBox>
|
||||
#include <QTableWidget>
|
||||
#include <QTableWidgetItem>
|
||||
#include <QTimer>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
QStringList splitList(const QString &text) {
|
||||
QStringList out;
|
||||
for (const QString &raw : text.split(QRegularExpression(QStringLiteral("[,\n]")))) {
|
||||
const QString s = raw.trimmed();
|
||||
if (!s.isEmpty()) {
|
||||
out << s;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- Page 1: project (client-side label only; grabber.start has no template concept) ---
|
||||
class GrabberProjectPage : public QWizardPage {
|
||||
public:
|
||||
explicit GrabberProjectPage(QWidget *parent) : QWizardPage(parent) {
|
||||
setTitle(QObject::tr("Project"));
|
||||
setSubTitle(QObject::tr("Give this crawl a name for your own reference."));
|
||||
name_ = new QLineEdit(this);
|
||||
name_->setPlaceholderText(QObject::tr("e.g. Photo gallery mirror"));
|
||||
auto *layout = new QFormLayout(this);
|
||||
layout->addRow(QObject::tr("Name:"), name_);
|
||||
}
|
||||
|
||||
private:
|
||||
QLineEdit *name_;
|
||||
};
|
||||
|
||||
// --- Page 2: start URL, depth, host/pattern filters -------------------------------------
|
||||
class GrabberStartPage : public QWizardPage {
|
||||
public:
|
||||
explicit GrabberStartPage(QWidget *parent) : QWizardPage(parent) {
|
||||
setTitle(QObject::tr("Start URL"));
|
||||
startUrl_ = new QLineEdit(this);
|
||||
startUrl_->setPlaceholderText(QStringLiteral("https://example.com/gallery/"));
|
||||
registerField(QStringLiteral("startUrl*"), startUrl_);
|
||||
depth_ = new QSpinBox(this);
|
||||
depth_->setRange(0, 10);
|
||||
depth_->setValue(1);
|
||||
sameHostOnly_ = new QCheckBox(QObject::tr("Stay on the same host"), this);
|
||||
sameHostOnly_->setChecked(true);
|
||||
includePatterns_ = new QLineEdit(this);
|
||||
includePatterns_->setPlaceholderText(QObject::tr("comma-separated globs, optional"));
|
||||
excludePatterns_ = new QLineEdit(this);
|
||||
excludePatterns_->setPlaceholderText(QObject::tr("comma-separated globs, optional"));
|
||||
|
||||
auto *layout = new QFormLayout(this);
|
||||
layout->addRow(QObject::tr("Start URL:"), startUrl_);
|
||||
layout->addRow(QObject::tr("Crawl depth:"), depth_);
|
||||
layout->addRow(sameHostOnly_);
|
||||
layout->addRow(QObject::tr("Include only:"), includePatterns_);
|
||||
layout->addRow(QObject::tr("Exclude:"), excludePatterns_);
|
||||
}
|
||||
|
||||
QString startUrl() const { return startUrl_->text().trimmed(); }
|
||||
int depth() const { return depth_->value(); }
|
||||
bool sameHostOnly() const { return sameHostOnly_->isChecked(); }
|
||||
QString includePatterns() const { return includePatterns_->text(); }
|
||||
QString excludePatterns() const { return excludePatterns_->text(); }
|
||||
|
||||
private:
|
||||
QLineEdit *startUrl_;
|
||||
QSpinBox *depth_;
|
||||
QCheckBox *sameHostOnly_;
|
||||
QLineEdit *includePatterns_;
|
||||
QLineEdit *excludePatterns_;
|
||||
};
|
||||
|
||||
// --- Page 3: file-type filter ------------------------------------------------------------
|
||||
class GrabberFileTypesPage : public QWizardPage {
|
||||
public:
|
||||
explicit GrabberFileTypesPage(QWidget *parent) : QWizardPage(parent) {
|
||||
setTitle(QObject::tr("File types"));
|
||||
setSubTitle(QObject::tr("Leave everything unchecked to crawl every file type."));
|
||||
images_ = new QCheckBox(QObject::tr("Images (jpg, png, gif, webp)"), this);
|
||||
archives_ = new QCheckBox(QObject::tr("Archives (zip, rar, 7z, tar, gz)"), this);
|
||||
docs_ = new QCheckBox(QObject::tr("Documents (pdf, doc, docx, xls, xlsx)"), this);
|
||||
video_ = new QCheckBox(QObject::tr("Video (mp4, mkv, avi, webm)"), this);
|
||||
audio_ = new QCheckBox(QObject::tr("Audio (mp3, flac, wav, ogg)"), this);
|
||||
custom_ = new QLineEdit(this);
|
||||
custom_->setPlaceholderText(QObject::tr("custom extensions, comma-separated"));
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addWidget(images_);
|
||||
layout->addWidget(archives_);
|
||||
layout->addWidget(docs_);
|
||||
layout->addWidget(video_);
|
||||
layout->addWidget(audio_);
|
||||
auto *customRow = new QFormLayout;
|
||||
customRow->addRow(QObject::tr("Also:"), custom_);
|
||||
layout->addLayout(customRow);
|
||||
}
|
||||
|
||||
bool images() const { return images_->isChecked(); }
|
||||
bool archives() const { return archives_->isChecked(); }
|
||||
bool docs() const { return docs_->isChecked(); }
|
||||
bool video() const { return video_->isChecked(); }
|
||||
bool audio() const { return audio_->isChecked(); }
|
||||
QString customCsv() const { return custom_->text(); }
|
||||
|
||||
private:
|
||||
QCheckBox *images_;
|
||||
QCheckBox *archives_;
|
||||
QCheckBox *docs_;
|
||||
QCheckBox *video_;
|
||||
QCheckBox *audio_;
|
||||
QLineEdit *custom_;
|
||||
};
|
||||
|
||||
// --- Page 4: crawl + review + harvest -----------------------------------------------------
|
||||
class GrabberReviewPage : public QWizardPage {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
GrabberReviewPage(rpc::RpcClient *client, QJsonArray categories, GrabberStartPage *startPage,
|
||||
GrabberFileTypesPage *fileTypesPage, QWidget *parent)
|
||||
: QWizardPage(parent),
|
||||
client_(client),
|
||||
startPage_(startPage),
|
||||
fileTypesPage_(fileTypesPage) {
|
||||
setTitle(QObject::tr("Review found files"));
|
||||
setFinalPage(true);
|
||||
|
||||
statusLabel_ = new QLabel(this);
|
||||
table_ = new QTableWidget(0, 3, this);
|
||||
table_->setHorizontalHeaderLabels(
|
||||
{QObject::tr("Get"), QObject::tr("File"), QObject::tr("Size")});
|
||||
table_->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
|
||||
table_->verticalHeader()->setVisible(false);
|
||||
table_->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
|
||||
auto *selectAll = new QPushButton(QObject::tr("Select All"), this);
|
||||
auto *selectNone = new QPushButton(QObject::tr("Select None"), this);
|
||||
connect(selectAll, &QPushButton::clicked, this, [this] { setAllChecked(true); });
|
||||
connect(selectNone, &QPushButton::clicked, this, [this] { setAllChecked(false); });
|
||||
auto *selectRow = new QHBoxLayout;
|
||||
selectRow->addWidget(selectAll);
|
||||
selectRow->addWidget(selectNone);
|
||||
selectRow->addStretch();
|
||||
|
||||
categoryCombo_ = new QComboBox(this);
|
||||
categoryCombo_->addItem(QObject::tr("(Automatic)"), QString());
|
||||
for (const QJsonValue &v : categories) {
|
||||
const QJsonObject c = v.toObject();
|
||||
categoryCombo_->addItem(c.value("name").toString(), c.value("categoryId").toString());
|
||||
}
|
||||
startModeCombo_ = new QComboBox(this);
|
||||
startModeCombo_->addItem(QObject::tr("Download Now"), QStringLiteral("now"));
|
||||
startModeCombo_->addItem(QObject::tr("Download Later"), QStringLiteral("later"));
|
||||
|
||||
errorLabel_ = new QLabel(this);
|
||||
errorLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
||||
errorLabel_->hide();
|
||||
|
||||
auto *footer = new QFormLayout;
|
||||
footer->addRow(QObject::tr("Category:"), categoryCombo_);
|
||||
footer->addRow(QObject::tr("Start mode:"), startModeCombo_);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addWidget(statusLabel_);
|
||||
layout->addLayout(selectRow);
|
||||
layout->addWidget(table_, 1);
|
||||
layout->addLayout(footer);
|
||||
layout->addWidget(errorLabel_);
|
||||
|
||||
pollTimer_ = new QTimer(this);
|
||||
pollTimer_->setInterval(5000); // catch-up fallback; live updates come from the event
|
||||
connect(pollTimer_, &QTimer::timeout, this, &GrabberReviewPage::fetchStatus);
|
||||
connect(client_, &rpc::RpcClient::grabberProgress, this, &GrabberReviewPage::onProgress);
|
||||
}
|
||||
|
||||
void initializePage() override {
|
||||
if (started_) {
|
||||
return;
|
||||
}
|
||||
started_ = true;
|
||||
statusLabel_->setText(QObject::tr("Starting crawl…"));
|
||||
|
||||
const QJsonValue fileTypes = GrabberWizard::buildFileTypes(
|
||||
fileTypesPage_->images(), fileTypesPage_->archives(), fileTypesPage_->docs(),
|
||||
fileTypesPage_->video(), fileTypesPage_->audio(), fileTypesPage_->customCsv());
|
||||
QJsonObject params = GrabberWizard::buildStartParams(
|
||||
startPage_->startUrl(), startPage_->depth(), startPage_->sameHostOnly(),
|
||||
startPage_->includePatterns(), startPage_->excludePatterns(), fileTypes);
|
||||
|
||||
QPointer<GrabberReviewPage> self(this);
|
||||
client_->call(
|
||||
QString::fromLatin1(rpc::method::kGrabberStart), params,
|
||||
[self](const rpc::RpcReply &reply) {
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
if (!reply.ok()) {
|
||||
self->statusLabel_->setText(
|
||||
QObject::tr("Could not start the crawl: %1").arg(reply.error.message));
|
||||
return;
|
||||
}
|
||||
self->jobId_ = reply.result.toObject().value("jobId").toString();
|
||||
self->pollTimer_->start();
|
||||
self->fetchStatus();
|
||||
});
|
||||
}
|
||||
|
||||
bool isComplete() const override { return !files_.isEmpty(); }
|
||||
|
||||
/// QWizard calls this on Finish and only advances (closes, for the last page) when it
|
||||
/// returns true. harvest is async, so this always returns false and closes the wizard
|
||||
/// itself from the reply callback instead — the same submit-then-accept shape every
|
||||
/// other dialog here uses, just routed through validatePage() rather than a button.
|
||||
bool validatePage() override {
|
||||
if (harvesting_) {
|
||||
return false;
|
||||
}
|
||||
QStringList selected;
|
||||
for (int row = 0; row < table_->rowCount(); ++row) {
|
||||
if (table_->item(row, 0)->checkState() == Qt::Checked) {
|
||||
selected << table_->item(row, 0)->data(Qt::UserRole).toString();
|
||||
}
|
||||
}
|
||||
if (selected.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
harvesting_ = true;
|
||||
const QJsonObject params = GrabberWizard::buildHarvestParams(
|
||||
jobId_, selected, categoryCombo_->currentData().toString(),
|
||||
startModeCombo_->currentData().toString());
|
||||
|
||||
QPointer<GrabberReviewPage> self(this);
|
||||
client_->call(
|
||||
QString::fromLatin1(rpc::method::kGrabberHarvest), params,
|
||||
[self](const rpc::RpcReply &reply) {
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
self->harvesting_ = false;
|
||||
if (!reply.ok()) {
|
||||
self->errorLabel_->setText(QObject::tr("Could not add the selected files: %1")
|
||||
.arg(reply.error.message));
|
||||
self->errorLabel_->show();
|
||||
return;
|
||||
}
|
||||
self->wizard()->accept();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
private:
|
||||
void setAllChecked(bool checked) {
|
||||
for (int row = 0; row < table_->rowCount(); ++row) {
|
||||
table_->item(row, 0)->setCheckState(checked ? Qt::Checked : Qt::Unchecked);
|
||||
}
|
||||
}
|
||||
|
||||
void onProgress(const QJsonObject ¶ms) {
|
||||
if (params.value("jobId").toString() != jobId_ || jobId_.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
statusLabel_->setText(QObject::tr("Crawled %1, found %2…")
|
||||
.arg(params.value("crawled").toInt())
|
||||
.arg(params.value("found").toInt()));
|
||||
if (params.value("done").toBool()) {
|
||||
fetchStatus();
|
||||
}
|
||||
}
|
||||
|
||||
void fetchStatus() {
|
||||
if (jobId_.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
QPointer<GrabberReviewPage> self(this);
|
||||
client_->call(QString::fromLatin1(rpc::method::kGrabberStatus),
|
||||
QJsonObject{{"jobId", jobId_}}, [self](const rpc::RpcReply &reply) {
|
||||
if (!self || !reply.ok()) {
|
||||
return;
|
||||
}
|
||||
self->applyStatus(reply.result.toObject());
|
||||
});
|
||||
}
|
||||
|
||||
void applyStatus(const QJsonObject &status) {
|
||||
const QString state = status.value("state").toString();
|
||||
files_ = status.value("files").toArray();
|
||||
populateTable();
|
||||
if (state == QLatin1String("crawling")) {
|
||||
statusLabel_->setText(QObject::tr("Crawling… %1 found so far").arg(files_.size()));
|
||||
} else if (state == QLatin1String("done")) {
|
||||
pollTimer_->stop();
|
||||
statusLabel_->setText(QObject::tr("Done — %1 file(s) found.").arg(files_.size()));
|
||||
} else {
|
||||
pollTimer_->stop();
|
||||
statusLabel_->setText(
|
||||
QObject::tr("Crawl %1: %2").arg(state, status.value("error").toString()));
|
||||
}
|
||||
emit completeChanged();
|
||||
}
|
||||
|
||||
void populateTable() {
|
||||
table_->setRowCount(files_.size());
|
||||
for (int row = 0; row < files_.size(); ++row) {
|
||||
const QJsonObject f = files_[row].toObject();
|
||||
auto *checkItem =
|
||||
new QTableWidgetItem(f.value("filename").toString(f.value("url").toString()));
|
||||
checkItem->setFlags(checkItem->flags() | Qt::ItemIsUserCheckable);
|
||||
checkItem->setCheckState(Qt::Checked);
|
||||
checkItem->setData(Qt::UserRole, f.value("fileId").toString());
|
||||
table_->setItem(row, 0, checkItem);
|
||||
table_->setItem(row, 1, new QTableWidgetItem(f.value("url").toString()));
|
||||
const QJsonValue size = f.value("sizeBytes");
|
||||
table_->setItem(
|
||||
row, 2,
|
||||
new QTableWidgetItem(size.isDouble() ? QLocale().formattedDataSize(
|
||||
static_cast<qint64>(size.toDouble()))
|
||||
: QObject::tr("unknown")));
|
||||
}
|
||||
}
|
||||
|
||||
rpc::RpcClient *client_;
|
||||
GrabberStartPage *startPage_;
|
||||
GrabberFileTypesPage *fileTypesPage_;
|
||||
QString jobId_;
|
||||
QJsonArray files_;
|
||||
bool started_ = false;
|
||||
bool harvesting_ = false;
|
||||
|
||||
QLabel *statusLabel_;
|
||||
QTableWidget *table_;
|
||||
QComboBox *categoryCombo_;
|
||||
QComboBox *startModeCombo_;
|
||||
QLabel *errorLabel_;
|
||||
QTimer *pollTimer_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
QJsonValue GrabberWizard::buildFileTypes(bool images, bool archives, bool docs, bool video,
|
||||
bool audio, const QString &customCsv) {
|
||||
QJsonArray types;
|
||||
if (images) {
|
||||
for (const char *e : {"jpg", "jpeg", "png", "gif", "webp"}) {
|
||||
types.append(e);
|
||||
}
|
||||
}
|
||||
if (archives) {
|
||||
for (const char *e : {"zip", "rar", "7z", "tar", "gz"}) {
|
||||
types.append(e);
|
||||
}
|
||||
}
|
||||
if (docs) {
|
||||
for (const char *e : {"pdf", "doc", "docx", "xls", "xlsx"}) {
|
||||
types.append(e);
|
||||
}
|
||||
}
|
||||
if (video) {
|
||||
for (const char *e : {"mp4", "mkv", "avi", "webm"}) {
|
||||
types.append(e);
|
||||
}
|
||||
}
|
||||
if (audio) {
|
||||
for (const char *e : {"mp3", "flac", "wav", "ogg"}) {
|
||||
types.append(e);
|
||||
}
|
||||
}
|
||||
for (const QString &e : splitList(customCsv)) {
|
||||
types.append(e);
|
||||
}
|
||||
return types.isEmpty() ? QJsonValue(QJsonValue::Null) : QJsonValue(types);
|
||||
}
|
||||
|
||||
QJsonObject GrabberWizard::buildStartParams(const QString &startUrl, int depth, bool sameHostOnly,
|
||||
const QString &includePatterns,
|
||||
const QString &excludePatterns,
|
||||
const QJsonValue &fileTypes) {
|
||||
const QStringList include = splitList(includePatterns);
|
||||
const QStringList exclude = splitList(excludePatterns);
|
||||
return QJsonObject{
|
||||
{"startUrl", startUrl},
|
||||
{"depth", depth},
|
||||
{"sameHostOnly", sameHostOnly},
|
||||
{"includePatterns", include.isEmpty() ? QJsonValue(QJsonValue::Null)
|
||||
: QJsonValue(QJsonArray::fromStringList(include))},
|
||||
{"excludePatterns", exclude.isEmpty() ? QJsonValue(QJsonValue::Null)
|
||||
: QJsonValue(QJsonArray::fromStringList(exclude))},
|
||||
{"fileTypes", fileTypes},
|
||||
};
|
||||
}
|
||||
|
||||
QJsonObject GrabberWizard::buildHarvestParams(const QString &jobId, const QStringList &selectedIds,
|
||||
const QString &categoryId, const QString &startMode) {
|
||||
QJsonObject defaults{{"url", QString()}, {"startMode", startMode}}; // url is ignored here
|
||||
if (!categoryId.isEmpty()) {
|
||||
defaults["categoryId"] = categoryId;
|
||||
}
|
||||
return QJsonObject{
|
||||
{"jobId", jobId},
|
||||
{"select", QJsonArray::fromStringList(selectedIds)},
|
||||
{"defaults", defaults},
|
||||
};
|
||||
}
|
||||
|
||||
GrabberWizard::GrabberWizard(rpc::RpcClient *client, QJsonArray categories, QWidget *parent)
|
||||
: QWizard(parent), client_(client), categories_(std::move(categories)) {
|
||||
setWindowTitle(tr("Site Grabber"));
|
||||
resize(640, 520);
|
||||
|
||||
addPage(new GrabberProjectPage(this));
|
||||
auto *startPage = new GrabberStartPage(this);
|
||||
addPage(startPage);
|
||||
auto *fileTypesPage = new GrabberFileTypesPage(this);
|
||||
addPage(fileTypesPage);
|
||||
addPage(new GrabberReviewPage(client_, categories_, startPage, fileTypesPage, this));
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
|
||||
#include "GrabberWizard.moc"
|
||||
@@ -0,0 +1,47 @@
|
||||
// Site Grabber wizard. Lane GUI.
|
||||
//
|
||||
// docs/03-gui-spec.md §5: 4 steps — project (client-side only; grabber.start has no
|
||||
// "template" concept) -> start URL + depth + filters -> file-type filter -> review found
|
||||
// files. grabber.start only crawls; nothing downloads until grabber.harvest, which is the
|
||||
// Finish button here.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QWizard>
|
||||
|
||||
namespace velox::gui {
|
||||
namespace rpc {
|
||||
class RpcClient;
|
||||
} // namespace rpc
|
||||
|
||||
class GrabberWizard : public QWizard {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
GrabberWizard(rpc::RpcClient *client, QJsonArray categories, QWidget *parent = nullptr);
|
||||
|
||||
/// Pure: the fileTypes value for grabber.start. Empty selection (every checkbox off,
|
||||
/// custom field blank) means "every type", which the schema spells as null rather than
|
||||
/// an empty array.
|
||||
static QJsonValue buildFileTypes(bool images, bool archives, bool docs, bool video, bool audio,
|
||||
const QString &customCsv);
|
||||
|
||||
/// Pure: grabber.start params from already-resolved field values. include/exclude are
|
||||
/// comma- or newline-separated glob lists; blank -> the schema's null ("no filter").
|
||||
static QJsonObject buildStartParams(const QString &startUrl, int depth, bool sameHostOnly,
|
||||
const QString &includePatterns,
|
||||
const QString &excludePatterns,
|
||||
const QJsonValue &fileTypes);
|
||||
|
||||
/// Pure: grabber.harvest params for the checked fileIds.
|
||||
static QJsonObject buildHarvestParams(const QString &jobId, const QStringList &selectedIds,
|
||||
const QString &categoryId, const QString &startMode);
|
||||
|
||||
private:
|
||||
rpc::RpcClient *client_;
|
||||
QJsonArray categories_;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,518 @@
|
||||
#include "dialogs/OptionsDialog.hpp"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFileDialog>
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QJsonArray>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QSpinBox>
|
||||
#include <QTabWidget>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
struct BufferChoice {
|
||||
const char *label;
|
||||
qint64 bytes;
|
||||
};
|
||||
// 64 KiB - 16 MiB, matching Settings.schema.json's connection.bufferBytes range.
|
||||
constexpr BufferChoice kBufferChoices[] = {
|
||||
{"64 KiB", 64 * 1024}, {"128 KiB", 128 * 1024}, {"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},
|
||||
};
|
||||
|
||||
void setBufferCombo(QComboBox *combo, qint64 bytes) {
|
||||
for (int i = 0; i < combo->count(); ++i) {
|
||||
if (combo->itemData(i).toLongLong() == bytes) {
|
||||
combo->setCurrentIndex(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
combo->setCurrentIndex(combo->count() / 2); // an unrecognized value: land near the middle
|
||||
}
|
||||
|
||||
QStringList splitHosts(const QString &text) {
|
||||
QStringList out;
|
||||
for (const QString &h : text.split(QLatin1Char(','), Qt::SkipEmptyParts)) {
|
||||
out << h.trimmed();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QStringList OptionsDialog::allKeys() {
|
||||
return {
|
||||
"general.launchOnLogin",
|
||||
"general.minimizeToTray",
|
||||
"general.showDropTarget",
|
||||
"general.confirmOnExit",
|
||||
"general.language",
|
||||
"general.checkForUpdates",
|
||||
"saveTo.defaultDir",
|
||||
"saveTo.tempDir",
|
||||
"saveTo.fileExistsPolicy",
|
||||
"saveTo.createSubfolderPerSite",
|
||||
"connection.preset",
|
||||
"connection.maxSegmentsPerDownload",
|
||||
"connection.bufferBytes",
|
||||
"connection.maxConcurrentDownloads",
|
||||
"connection.timeoutSec",
|
||||
"connection.maxRetries",
|
||||
"connection.retryBackoffSec",
|
||||
"connection.maxTotalBufferBytes",
|
||||
"connection.maxActiveSegments",
|
||||
"downloads.speedLimitEnabled",
|
||||
"downloads.speedLimitBps",
|
||||
"downloads.virusScanCommand",
|
||||
"downloads.postDownloadCommand",
|
||||
"downloads.duplicatePolicy",
|
||||
"downloads.verifyChecksums",
|
||||
"proxy.mode",
|
||||
"proxy.host",
|
||||
"proxy.port",
|
||||
"proxy.username",
|
||||
"proxy.bypassHosts",
|
||||
"proxy.pacUrl",
|
||||
"sounds.enabled",
|
||||
"sounds.onComplete",
|
||||
"sounds.onQueueComplete",
|
||||
"sounds.onError",
|
||||
};
|
||||
}
|
||||
|
||||
QJsonObject OptionsDialog::diffChanged(const QJsonObject &original, const QJsonObject ¤t) {
|
||||
QJsonObject changed;
|
||||
for (auto it = current.constBegin(); it != current.constEnd(); ++it) {
|
||||
if (!original.contains(it.key()) || original.value(it.key()) != it.value()) {
|
||||
changed.insert(it.key(), it.value());
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
OptionsDialog::OptionsDialog(rpc::RpcClient *client, QWidget *parent)
|
||||
: QDialog(parent),
|
||||
client_(client),
|
||||
tabs_(new QTabWidget(this)),
|
||||
statusLabel_(new QLabel(this)) {
|
||||
setWindowTitle(tr("Options"));
|
||||
resize(560, 480);
|
||||
|
||||
buildGeneralTab();
|
||||
buildSaveToTab();
|
||||
buildConnectionTab();
|
||||
buildDownloadsTab();
|
||||
buildProxyTab();
|
||||
buildSoundsTab();
|
||||
|
||||
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
||||
statusLabel_->hide();
|
||||
|
||||
auto *buttons = new QDialogButtonBox(
|
||||
QDialogButtonBox::Ok | QDialogButtonBox::Apply | QDialogButtonBox::Cancel, this);
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, [this] {
|
||||
apply();
|
||||
accept();
|
||||
});
|
||||
connect(buttons->button(QDialogButtonBox::Apply), &QPushButton::clicked, this,
|
||||
&OptionsDialog::apply);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addWidget(tabs_);
|
||||
layout->addWidget(statusLabel_);
|
||||
layout->addWidget(buttons);
|
||||
|
||||
tabs_->setEnabled(false); // stays disabled until settings.get answers
|
||||
|
||||
QPointer<OptionsDialog> self(this);
|
||||
client_->call(QString::fromLatin1(rpc::method::kSettingsGet),
|
||||
QJsonObject{{"keys", QJsonArray::fromStringList(allKeys())}},
|
||||
[self](const rpc::RpcReply &reply) {
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
if (!reply.ok()) {
|
||||
self->statusLabel_->setText(
|
||||
tr("Could not load settings: %1").arg(reply.error.message));
|
||||
self->statusLabel_->show();
|
||||
return;
|
||||
}
|
||||
self->onSettingsLoaded(reply.result.toObject().value("values").toObject());
|
||||
});
|
||||
}
|
||||
|
||||
void OptionsDialog::onSettingsLoaded(const QJsonObject &values) {
|
||||
original_ = values;
|
||||
populateFrom(values);
|
||||
tabs_->setEnabled(true);
|
||||
}
|
||||
|
||||
void OptionsDialog::buildGeneralTab() {
|
||||
auto *page = new QWidget(this);
|
||||
launchOnLogin_ = new QCheckBox(tr("Launch on login"), page);
|
||||
minimizeToTray_ = new QCheckBox(tr("Minimize to tray"), page);
|
||||
showDropTarget_ = new QCheckBox(tr("Show floating drop target"), page);
|
||||
confirmOnExit_ = new QCheckBox(tr("Confirm on exit"), page);
|
||||
language_ = new QComboBox(page);
|
||||
language_->addItem(tr("System default"), QStringLiteral("system"));
|
||||
language_->addItem(tr("English"), QStringLiteral("en"));
|
||||
language_->addItem(tr("العربية"), QStringLiteral("ar"));
|
||||
checkForUpdates_ = new QCheckBox(tr("Check for updates"), page);
|
||||
|
||||
auto *form = new QFormLayout(page);
|
||||
form->addRow(launchOnLogin_);
|
||||
form->addRow(minimizeToTray_);
|
||||
form->addRow(showDropTarget_);
|
||||
form->addRow(confirmOnExit_);
|
||||
form->addRow(tr("Language:"), language_);
|
||||
form->addRow(checkForUpdates_);
|
||||
tabs_->addTab(page, tr("General"));
|
||||
}
|
||||
|
||||
void OptionsDialog::buildSaveToTab() {
|
||||
auto *page = new QWidget(this);
|
||||
defaultDir_ = new QLineEdit(page);
|
||||
tempDir_ = new QLineEdit(page);
|
||||
fileExistsPolicy_ = new QComboBox(page);
|
||||
fileExistsPolicy_->addItem(tr("Ask"), QStringLiteral("ask"));
|
||||
fileExistsPolicy_->addItem(tr("Rename"), QStringLiteral("rename"));
|
||||
fileExistsPolicy_->addItem(tr("Overwrite"), QStringLiteral("overwrite"));
|
||||
fileExistsPolicy_->addItem(tr("Resume"), QStringLiteral("resume"));
|
||||
createSubfolderPerSite_ = new QCheckBox(tr("Create a subfolder per site"), page);
|
||||
|
||||
auto *defaultDirRow = new QWidget(page);
|
||||
auto *defaultDirLayout = new QHBoxLayout(defaultDirRow);
|
||||
defaultDirLayout->setContentsMargins(0, 0, 0, 0);
|
||||
auto *browseDefault = new QPushButton(tr("Browse…"), defaultDirRow);
|
||||
connect(browseDefault, &QPushButton::clicked, this, [this] { browseInto(defaultDir_, false); });
|
||||
defaultDirLayout->addWidget(defaultDir_, 1);
|
||||
defaultDirLayout->addWidget(browseDefault);
|
||||
|
||||
auto *tempDirRow = new QWidget(page);
|
||||
auto *tempDirLayout = new QHBoxLayout(tempDirRow);
|
||||
tempDirLayout->setContentsMargins(0, 0, 0, 0);
|
||||
auto *browseTemp = new QPushButton(tr("Browse…"), tempDirRow);
|
||||
connect(browseTemp, &QPushButton::clicked, this, [this] { browseInto(tempDir_, false); });
|
||||
tempDirLayout->addWidget(tempDir_, 1);
|
||||
tempDirLayout->addWidget(browseTemp);
|
||||
|
||||
auto *form = new QFormLayout(page);
|
||||
form->addRow(tr("Default download directory:"), defaultDirRow);
|
||||
form->addRow(tr("Temp folder:"), tempDirRow);
|
||||
form->addRow(tr("If a file already exists:"), fileExistsPolicy_);
|
||||
form->addRow(createSubfolderPerSite_);
|
||||
tabs_->addTab(page, tr("Save To"));
|
||||
}
|
||||
|
||||
void OptionsDialog::buildConnectionTab() {
|
||||
auto *page = new QWidget(this);
|
||||
connectionPreset_ = new QComboBox(page);
|
||||
connectionPreset_->addItem(tr("Auto"), QStringLiteral("auto"));
|
||||
connectionPreset_->addItem(tr("LAN"), QStringLiteral("lan"));
|
||||
connectionPreset_->addItem(tr("Broadband"), QStringLiteral("broadband"));
|
||||
connectionPreset_->addItem(tr("Slow"), QStringLiteral("slow"));
|
||||
|
||||
maxSegmentsPerDownload_ = new QSpinBox(page);
|
||||
maxSegmentsPerDownload_->setRange(1, 32);
|
||||
|
||||
bufferBytes_ = new QComboBox(page);
|
||||
for (const auto &choice : kBufferChoices) {
|
||||
bufferBytes_->addItem(QString::fromLatin1(choice.label), QVariant::fromValue(choice.bytes));
|
||||
}
|
||||
|
||||
maxConcurrentDownloads_ = new QSpinBox(page);
|
||||
maxConcurrentDownloads_->setRange(1, 64);
|
||||
|
||||
timeoutSec_ = new QSpinBox(page);
|
||||
timeoutSec_->setRange(1, 3600);
|
||||
timeoutSec_->setSuffix(tr(" s"));
|
||||
|
||||
maxRetries_ = new QSpinBox(page);
|
||||
maxRetries_->setRange(0, 100);
|
||||
|
||||
retryBackoffSec_ = new QSpinBox(page);
|
||||
retryBackoffSec_->setRange(0, 3600);
|
||||
retryBackoffSec_->setSuffix(tr(" s"));
|
||||
|
||||
maxTotalBufferMiB_ = new QSpinBox(page);
|
||||
maxTotalBufferMiB_->setRange(16, 2048);
|
||||
maxTotalBufferMiB_->setSuffix(tr(" MiB"));
|
||||
|
||||
maxActiveSegments_ = new QSpinBox(page);
|
||||
maxActiveSegments_->setRange(1, 256);
|
||||
|
||||
auto *form = new QFormLayout(page);
|
||||
form->addRow(tr("Connection type:"), connectionPreset_);
|
||||
form->addRow(tr("Max connections per download:"), maxSegmentsPerDownload_);
|
||||
form->addRow(tr("Write buffer per connection:"), bufferBytes_);
|
||||
form->addRow(tr("Max concurrent downloads:"), maxConcurrentDownloads_);
|
||||
form->addRow(tr("Timeout:"), timeoutSec_);
|
||||
form->addRow(tr("Max retries:"), maxRetries_);
|
||||
form->addRow(tr("Retry backoff:"), retryBackoffSec_);
|
||||
form->addRow(tr("Max total buffer memory:"), maxTotalBufferMiB_);
|
||||
form->addRow(tr("Max active segments (global):"), maxActiveSegments_);
|
||||
tabs_->addTab(page, tr("Connection"));
|
||||
}
|
||||
|
||||
void OptionsDialog::buildDownloadsTab() {
|
||||
auto *page = new QWidget(this);
|
||||
speedLimitEnabled_ = new QCheckBox(tr("Limit download speed by default"), page);
|
||||
speedLimitKiBps_ = new QSpinBox(page);
|
||||
speedLimitKiBps_->setRange(0, 1000000);
|
||||
speedLimitKiBps_->setSuffix(tr(" KiB/s"));
|
||||
connect(speedLimitEnabled_, &QCheckBox::toggled, speedLimitKiBps_, &QWidget::setEnabled);
|
||||
|
||||
virusScanCommand_ = new QLineEdit(page);
|
||||
virusScanCommand_->setPlaceholderText(tr("e.g. clamscan %f"));
|
||||
postDownloadCommand_ = new QLineEdit(page);
|
||||
postDownloadCommand_->setPlaceholderText(tr("e.g. notify-send Downloaded %f"));
|
||||
|
||||
duplicatePolicy_ = new QComboBox(page);
|
||||
duplicatePolicy_->addItem(tr("Ask"), QStringLiteral("ask"));
|
||||
duplicatePolicy_->addItem(tr("Skip"), QStringLiteral("skip"));
|
||||
duplicatePolicy_->addItem(tr("Rename"), QStringLiteral("rename"));
|
||||
duplicatePolicy_->addItem(tr("Redownload"), QStringLiteral("redownload"));
|
||||
|
||||
verifyChecksums_ = new QCheckBox(tr("Verify checksums when available"), page);
|
||||
|
||||
auto *form = new QFormLayout(page);
|
||||
form->addRow(speedLimitEnabled_);
|
||||
form->addRow(tr("Speed limit:"), speedLimitKiBps_);
|
||||
form->addRow(tr("Virus-scan command:"), virusScanCommand_);
|
||||
form->addRow(tr("Post-download command:"), postDownloadCommand_);
|
||||
form->addRow(tr("Duplicate URL:"), duplicatePolicy_);
|
||||
form->addRow(verifyChecksums_);
|
||||
tabs_->addTab(page, tr("Downloads"));
|
||||
}
|
||||
|
||||
void OptionsDialog::buildProxyTab() {
|
||||
auto *page = new QWidget(this);
|
||||
proxyMode_ = new QComboBox(page);
|
||||
proxyMode_->addItem(tr("System"), QStringLiteral("system"));
|
||||
proxyMode_->addItem(tr("None"), QStringLiteral("none"));
|
||||
proxyMode_->addItem(tr("HTTP"), QStringLiteral("http"));
|
||||
proxyMode_->addItem(tr("HTTPS"), QStringLiteral("https"));
|
||||
proxyMode_->addItem(tr("SOCKS5"), QStringLiteral("socks5"));
|
||||
proxyMode_->addItem(tr("Automatic (PAC)"), QStringLiteral("pac"));
|
||||
|
||||
proxyHost_ = new QLineEdit(page);
|
||||
proxyPort_ = new QSpinBox(page);
|
||||
proxyPort_->setRange(1, 65535);
|
||||
proxyUsername_ = new QLineEdit(page);
|
||||
proxyBypassHosts_ = new QLineEdit(page);
|
||||
proxyBypassHosts_->setPlaceholderText(tr("comma-separated, e.g. localhost, *.internal"));
|
||||
proxyPacUrl_ = new QLineEdit(page);
|
||||
|
||||
const auto updateEnabled = [this] {
|
||||
const QString mode = proxyMode_->currentData().toString();
|
||||
const bool manual = mode == QLatin1String("http") || mode == QLatin1String("https") ||
|
||||
mode == QLatin1String("socks5");
|
||||
proxyHost_->setEnabled(manual);
|
||||
proxyPort_->setEnabled(manual);
|
||||
proxyUsername_->setEnabled(manual);
|
||||
proxyPacUrl_->setEnabled(mode == QLatin1String("pac"));
|
||||
};
|
||||
connect(proxyMode_, &QComboBox::currentIndexChanged, this, updateEnabled);
|
||||
updateEnabled();
|
||||
|
||||
auto *form = new QFormLayout(page);
|
||||
form->addRow(tr("Mode:"), proxyMode_);
|
||||
form->addRow(tr("Host:"), proxyHost_);
|
||||
form->addRow(tr("Port:"), proxyPort_);
|
||||
form->addRow(tr("Username:"), proxyUsername_);
|
||||
form->addRow(
|
||||
new QLabel(tr("(password is stored in the Secret Service, set separately)"), page));
|
||||
form->addRow(tr("Bypass for:"), proxyBypassHosts_);
|
||||
form->addRow(tr("PAC URL:"), proxyPacUrl_);
|
||||
tabs_->addTab(page, tr("Proxy"));
|
||||
}
|
||||
|
||||
void OptionsDialog::buildSoundsTab() {
|
||||
auto *page = new QWidget(this);
|
||||
soundsEnabled_ = new QCheckBox(tr("Play sounds"), page);
|
||||
|
||||
const auto soundRow = [this, page](QLineEdit *&edit) {
|
||||
auto *row = new QWidget(page);
|
||||
auto *layout = new QHBoxLayout(row);
|
||||
layout->setContentsMargins(0, 0, 0, 0);
|
||||
edit = new QLineEdit(row);
|
||||
auto *browse = new QPushButton(tr("Browse…"), row);
|
||||
connect(browse, &QPushButton::clicked, this, [this, edit] { browseInto(edit, true); });
|
||||
layout->addWidget(edit, 1);
|
||||
layout->addWidget(browse);
|
||||
return row;
|
||||
};
|
||||
|
||||
auto *completeRow = soundRow(soundOnComplete_);
|
||||
auto *queueRow = soundRow(soundOnQueueComplete_);
|
||||
auto *errorRow = soundRow(soundOnError_);
|
||||
connect(soundsEnabled_, &QCheckBox::toggled, completeRow, &QWidget::setEnabled);
|
||||
connect(soundsEnabled_, &QCheckBox::toggled, queueRow, &QWidget::setEnabled);
|
||||
connect(soundsEnabled_, &QCheckBox::toggled, errorRow, &QWidget::setEnabled);
|
||||
|
||||
auto *form = new QFormLayout(page);
|
||||
form->addRow(soundsEnabled_);
|
||||
form->addRow(tr("Download complete:"), completeRow);
|
||||
form->addRow(tr("Queue complete:"), queueRow);
|
||||
form->addRow(tr("Error:"), errorRow);
|
||||
tabs_->addTab(page, tr("Sounds"));
|
||||
}
|
||||
|
||||
void OptionsDialog::browseInto(QLineEdit *target, bool pickFile) {
|
||||
const QString picked =
|
||||
pickFile ? QFileDialog::getOpenFileName(this, tr("Choose a sound file"), target->text())
|
||||
: QFileDialog::getExistingDirectory(this, tr("Choose a folder"), target->text());
|
||||
if (!picked.isEmpty()) {
|
||||
target->setText(picked);
|
||||
}
|
||||
}
|
||||
|
||||
void OptionsDialog::populateFrom(const QJsonObject &v) {
|
||||
launchOnLogin_->setChecked(v.value("general.launchOnLogin").toBool());
|
||||
minimizeToTray_->setChecked(v.value("general.minimizeToTray").toBool());
|
||||
showDropTarget_->setChecked(v.value("general.showDropTarget").toBool(true));
|
||||
confirmOnExit_->setChecked(v.value("general.confirmOnExit").toBool());
|
||||
const int langIdx = language_->findData(v.value("general.language").toString("system"));
|
||||
language_->setCurrentIndex(langIdx >= 0 ? langIdx : 0);
|
||||
checkForUpdates_->setChecked(v.value("general.checkForUpdates").toBool(true));
|
||||
|
||||
defaultDir_->setText(v.value("saveTo.defaultDir").toString());
|
||||
tempDir_->setText(v.value("saveTo.tempDir").toString());
|
||||
const int policyIdx =
|
||||
fileExistsPolicy_->findData(v.value("saveTo.fileExistsPolicy").toString("ask"));
|
||||
fileExistsPolicy_->setCurrentIndex(policyIdx >= 0 ? policyIdx : 0);
|
||||
createSubfolderPerSite_->setChecked(v.value("saveTo.createSubfolderPerSite").toBool());
|
||||
|
||||
const int presetIdx =
|
||||
connectionPreset_->findData(v.value("connection.preset").toString("auto"));
|
||||
connectionPreset_->setCurrentIndex(presetIdx >= 0 ? presetIdx : 0);
|
||||
maxSegmentsPerDownload_->setValue(
|
||||
static_cast<int>(v.value("connection.maxSegmentsPerDownload").toInt(8)));
|
||||
setBufferCombo(bufferBytes_,
|
||||
static_cast<qint64>(v.value("connection.bufferBytes").toDouble(1048576)));
|
||||
maxConcurrentDownloads_->setValue(
|
||||
static_cast<int>(v.value("connection.maxConcurrentDownloads").toInt(5)));
|
||||
timeoutSec_->setValue(static_cast<int>(v.value("connection.timeoutSec").toInt(30)));
|
||||
maxRetries_->setValue(static_cast<int>(v.value("connection.maxRetries").toInt(3)));
|
||||
retryBackoffSec_->setValue(static_cast<int>(v.value("connection.retryBackoffSec").toInt(5)));
|
||||
maxTotalBufferMiB_->setValue(static_cast<int>(
|
||||
v.value("connection.maxTotalBufferBytes").toDouble(134217728) / (1024 * 1024)));
|
||||
maxActiveSegments_->setValue(
|
||||
static_cast<int>(v.value("connection.maxActiveSegments").toInt(32)));
|
||||
|
||||
speedLimitEnabled_->setChecked(v.value("downloads.speedLimitEnabled").toBool());
|
||||
speedLimitKiBps_->setValue(
|
||||
static_cast<int>(v.value("downloads.speedLimitBps").toDouble() / 1024));
|
||||
speedLimitKiBps_->setEnabled(speedLimitEnabled_->isChecked());
|
||||
virusScanCommand_->setText(v.value("downloads.virusScanCommand").toString());
|
||||
postDownloadCommand_->setText(v.value("downloads.postDownloadCommand").toString());
|
||||
const int dupIdx =
|
||||
duplicatePolicy_->findData(v.value("downloads.duplicatePolicy").toString("ask"));
|
||||
duplicatePolicy_->setCurrentIndex(dupIdx >= 0 ? dupIdx : 0);
|
||||
verifyChecksums_->setChecked(v.value("downloads.verifyChecksums").toBool());
|
||||
|
||||
const int modeIdx = proxyMode_->findData(v.value("proxy.mode").toString("system"));
|
||||
proxyMode_->setCurrentIndex(modeIdx >= 0 ? modeIdx : 0);
|
||||
proxyHost_->setText(v.value("proxy.host").toString());
|
||||
proxyPort_->setValue(v.value("proxy.port").toInt(1080));
|
||||
proxyUsername_->setText(v.value("proxy.username").toString());
|
||||
QStringList bypass;
|
||||
for (const QJsonValue &h : v.value("proxy.bypassHosts").toArray()) {
|
||||
bypass << h.toString();
|
||||
}
|
||||
proxyBypassHosts_->setText(bypass.join(QStringLiteral(", ")));
|
||||
proxyPacUrl_->setText(v.value("proxy.pacUrl").toString());
|
||||
|
||||
soundsEnabled_->setChecked(v.value("sounds.enabled").toBool());
|
||||
soundOnComplete_->setText(v.value("sounds.onComplete").toString());
|
||||
soundOnQueueComplete_->setText(v.value("sounds.onQueueComplete").toString());
|
||||
soundOnError_->setText(v.value("sounds.onError").toString());
|
||||
}
|
||||
|
||||
QJsonObject OptionsDialog::currentValues() const {
|
||||
QJsonObject v;
|
||||
v["general.launchOnLogin"] = launchOnLogin_->isChecked();
|
||||
v["general.minimizeToTray"] = minimizeToTray_->isChecked();
|
||||
v["general.showDropTarget"] = showDropTarget_->isChecked();
|
||||
v["general.confirmOnExit"] = confirmOnExit_->isChecked();
|
||||
v["general.language"] = language_->currentData().toString();
|
||||
v["general.checkForUpdates"] = checkForUpdates_->isChecked();
|
||||
|
||||
v["saveTo.defaultDir"] = defaultDir_->text();
|
||||
v["saveTo.tempDir"] = tempDir_->text();
|
||||
v["saveTo.fileExistsPolicy"] = fileExistsPolicy_->currentData().toString();
|
||||
v["saveTo.createSubfolderPerSite"] = createSubfolderPerSite_->isChecked();
|
||||
|
||||
v["connection.preset"] = connectionPreset_->currentData().toString();
|
||||
v["connection.maxSegmentsPerDownload"] = maxSegmentsPerDownload_->value();
|
||||
v["connection.bufferBytes"] = bufferBytes_->currentData().toLongLong();
|
||||
v["connection.maxConcurrentDownloads"] = maxConcurrentDownloads_->value();
|
||||
v["connection.timeoutSec"] = timeoutSec_->value();
|
||||
v["connection.maxRetries"] = maxRetries_->value();
|
||||
v["connection.retryBackoffSec"] = retryBackoffSec_->value();
|
||||
v["connection.maxTotalBufferBytes"] =
|
||||
static_cast<qint64>(maxTotalBufferMiB_->value()) * 1024 * 1024;
|
||||
v["connection.maxActiveSegments"] = maxActiveSegments_->value();
|
||||
|
||||
v["downloads.speedLimitEnabled"] = speedLimitEnabled_->isChecked();
|
||||
v["downloads.speedLimitBps"] = static_cast<qint64>(speedLimitKiBps_->value()) * 1024;
|
||||
v["downloads.virusScanCommand"] = virusScanCommand_->text();
|
||||
v["downloads.postDownloadCommand"] = postDownloadCommand_->text();
|
||||
v["downloads.duplicatePolicy"] = duplicatePolicy_->currentData().toString();
|
||||
v["downloads.verifyChecksums"] = verifyChecksums_->isChecked();
|
||||
|
||||
v["proxy.mode"] = proxyMode_->currentData().toString();
|
||||
v["proxy.host"] = proxyHost_->text();
|
||||
v["proxy.port"] = proxyPort_->value();
|
||||
v["proxy.username"] = proxyUsername_->text();
|
||||
v["proxy.bypassHosts"] = QJsonArray::fromStringList(splitHosts(proxyBypassHosts_->text()));
|
||||
v["proxy.pacUrl"] = proxyPacUrl_->text();
|
||||
|
||||
v["sounds.enabled"] = soundsEnabled_->isChecked();
|
||||
v["sounds.onComplete"] = soundOnComplete_->text();
|
||||
v["sounds.onQueueComplete"] = soundOnQueueComplete_->text();
|
||||
v["sounds.onError"] = soundOnError_->text();
|
||||
return v;
|
||||
}
|
||||
|
||||
void OptionsDialog::apply() {
|
||||
const QJsonObject changed = diffChanged(original_, currentValues());
|
||||
if (changed.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
QPointer<OptionsDialog> self(this);
|
||||
client_->call(QString::fromLatin1(rpc::method::kSettingsSet), QJsonObject{{"values", changed}},
|
||||
[self](const rpc::RpcReply &reply) {
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
if (!reply.ok()) {
|
||||
self->statusLabel_->setText(
|
||||
tr("Could not save settings: %1").arg(reply.error.message));
|
||||
self->statusLabel_->show();
|
||||
return;
|
||||
}
|
||||
self->statusLabel_->hide();
|
||||
const QJsonObject written =
|
||||
reply.result.toObject().value("values").toObject();
|
||||
for (auto it = written.constBegin(); it != written.constEnd(); ++it) {
|
||||
self->original_.insert(it.key(), it.value());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,110 @@
|
||||
// The Options dialog. Lane GUI.
|
||||
//
|
||||
// docs/03-gui-spec.md §4: every control here maps 1:1 onto a settings.* key from
|
||||
// contracts/schema/types/Settings.schema.json. The spec's "File Types" and "Site Logins"
|
||||
// tabs have no backing key (per-category extension lists live on Category via
|
||||
// category.upsert, not settings.*; login credentials go to the Secret Service) — a real
|
||||
// tab either binds to a real key or does not exist here, so those two are left out rather
|
||||
// than shipped as fake affordances.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <QJsonObject>
|
||||
|
||||
class QCheckBox;
|
||||
class QComboBox;
|
||||
class QLabel;
|
||||
class QLineEdit;
|
||||
class QSpinBox;
|
||||
class QTabWidget;
|
||||
|
||||
namespace velox::gui {
|
||||
namespace rpc {
|
||||
class RpcClient;
|
||||
} // namespace rpc
|
||||
|
||||
class OptionsDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit OptionsDialog(rpc::RpcClient *client, QWidget *parent = nullptr);
|
||||
|
||||
/// The settings.* keys every tab's controls bind to, in the order settings.get is
|
||||
/// called with. Exposed so a test can assert nothing here drifts from the schema.
|
||||
static QStringList allKeys();
|
||||
|
||||
/// Pure: keys in `current` whose value differs from `original` (or is altogether
|
||||
/// absent from `original`). settings.set is documented to write and broadcast exactly
|
||||
/// the keys present in its params, so sending only what actually changed matters.
|
||||
static QJsonObject diffChanged(const QJsonObject &original, const QJsonObject ¤t);
|
||||
|
||||
private slots:
|
||||
void onSettingsLoaded(const QJsonObject &values);
|
||||
void apply();
|
||||
|
||||
private:
|
||||
void buildGeneralTab();
|
||||
void buildSaveToTab();
|
||||
void buildConnectionTab();
|
||||
void buildDownloadsTab();
|
||||
void buildProxyTab();
|
||||
void buildSoundsTab();
|
||||
void populateFrom(const QJsonObject &values);
|
||||
QJsonObject currentValues() const;
|
||||
void browseInto(QLineEdit *target, bool pickFile);
|
||||
|
||||
rpc::RpcClient *client_;
|
||||
QJsonObject original_; // last-known-saved values, keyed by settings.* name
|
||||
QTabWidget *tabs_;
|
||||
QLabel *statusLabel_;
|
||||
|
||||
// General
|
||||
QCheckBox *launchOnLogin_;
|
||||
QCheckBox *minimizeToTray_;
|
||||
QCheckBox *showDropTarget_;
|
||||
QCheckBox *confirmOnExit_;
|
||||
QComboBox *language_;
|
||||
QCheckBox *checkForUpdates_;
|
||||
|
||||
// Save To
|
||||
QLineEdit *defaultDir_;
|
||||
QLineEdit *tempDir_;
|
||||
QComboBox *fileExistsPolicy_;
|
||||
QCheckBox *createSubfolderPerSite_;
|
||||
|
||||
// Connection
|
||||
QComboBox *connectionPreset_;
|
||||
QSpinBox *maxSegmentsPerDownload_;
|
||||
QComboBox *bufferBytes_;
|
||||
QSpinBox *maxConcurrentDownloads_;
|
||||
QSpinBox *timeoutSec_;
|
||||
QSpinBox *maxRetries_;
|
||||
QSpinBox *retryBackoffSec_;
|
||||
QSpinBox *maxTotalBufferMiB_;
|
||||
QSpinBox *maxActiveSegments_;
|
||||
|
||||
// Downloads
|
||||
QCheckBox *speedLimitEnabled_;
|
||||
QSpinBox *speedLimitKiBps_;
|
||||
QLineEdit *virusScanCommand_;
|
||||
QLineEdit *postDownloadCommand_;
|
||||
QComboBox *duplicatePolicy_;
|
||||
QCheckBox *verifyChecksums_;
|
||||
|
||||
// Proxy
|
||||
QComboBox *proxyMode_;
|
||||
QLineEdit *proxyHost_;
|
||||
QSpinBox *proxyPort_;
|
||||
QLineEdit *proxyUsername_;
|
||||
QLineEdit *proxyBypassHosts_;
|
||||
QLineEdit *proxyPacUrl_;
|
||||
|
||||
// Sounds
|
||||
QCheckBox *soundsEnabled_;
|
||||
QLineEdit *soundOnComplete_;
|
||||
QLineEdit *soundOnQueueComplete_;
|
||||
QLineEdit *soundOnError_;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,321 @@
|
||||
#include "dialogs/SchedulerDialog.hpp"
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QDateEdit>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QJsonArray>
|
||||
#include <QLabel>
|
||||
#include <QListWidget>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QSpinBox>
|
||||
#include <QTimeEdit>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
// 0 = Sunday, matching Schedule.schema.json's daysOfWeek.
|
||||
constexpr const char *kDayLabels[7] = {
|
||||
QT_TR_NOOP("Sun"), QT_TR_NOOP("Mon"), QT_TR_NOOP("Tue"), QT_TR_NOOP("Wed"),
|
||||
QT_TR_NOOP("Thu"), QT_TR_NOOP("Fri"), QT_TR_NOOP("Sat"),
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
QJsonValue SchedulerDialog::buildSchedule(bool useSchedule, const QString &mode,
|
||||
const QString &startTime, bool runUntilDrains,
|
||||
const QString &stopTime, const QList<int> &daysOfWeek,
|
||||
const QString &onceDate) {
|
||||
if (!useSchedule) {
|
||||
return QJsonValue(QJsonValue::Null);
|
||||
}
|
||||
QJsonObject s{
|
||||
{"enabled", true},
|
||||
{"mode", mode},
|
||||
{"startTime", startTime},
|
||||
{"stopTime", runUntilDrains ? QJsonValue(QJsonValue::Null) : QJsonValue(stopTime)},
|
||||
};
|
||||
if (mode == QLatin1String("periodic")) {
|
||||
QJsonArray days;
|
||||
for (int d : daysOfWeek) {
|
||||
days.append(d);
|
||||
}
|
||||
s["daysOfWeek"] = days;
|
||||
} else {
|
||||
s["onceDate"] = onceDate;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
SchedulerDialog::SchedulerDialog(rpc::RpcClient *client, QWidget *parent)
|
||||
: QDialog(parent),
|
||||
client_(client),
|
||||
queueList_(new QListWidget(this)),
|
||||
useSchedule_(new QCheckBox(tr("Run this queue on a schedule"), this)),
|
||||
mode_(new QComboBox(this)),
|
||||
startTime_(new QTimeEdit(this)),
|
||||
runUntilDrains_(new QCheckBox(tr("Run until the queue drains"), this)),
|
||||
stopTime_(new QTimeEdit(this)),
|
||||
onceDate_(new QDateEdit(this)),
|
||||
maxConcurrent_(new QSpinBox(this)),
|
||||
onComplete_(new QComboBox(this)),
|
||||
pauseRunningOnStop_(new QCheckBox(tr("Pause running tasks too"), this)),
|
||||
statusLabel_(new QLabel(this)) {
|
||||
setWindowTitle(tr("Scheduler"));
|
||||
resize(640, 420);
|
||||
|
||||
queueList_->setMaximumWidth(200);
|
||||
|
||||
mode_->addItem(tr("Once"), QStringLiteral("once"));
|
||||
mode_->addItem(tr("Periodic (weekly)"), QStringLiteral("periodic"));
|
||||
startTime_->setDisplayFormat(QStringLiteral("HH:mm"));
|
||||
stopTime_->setDisplayFormat(QStringLiteral("HH:mm"));
|
||||
onceDate_->setCalendarPopup(true);
|
||||
maxConcurrent_->setRange(1, 32);
|
||||
onComplete_->addItem(tr("Do nothing"), QStringLiteral("nothing"));
|
||||
onComplete_->addItem(tr("Exit Velox"), QStringLiteral("exit"));
|
||||
onComplete_->addItem(tr("Shut down"), QStringLiteral("shutdown"));
|
||||
onComplete_->addItem(tr("Hang up (disconnect network)"), QStringLiteral("hangup"));
|
||||
|
||||
auto *daysRow = new QWidget(this);
|
||||
auto *daysLayout = new QHBoxLayout(daysRow);
|
||||
daysLayout->setContentsMargins(0, 0, 0, 0);
|
||||
for (int i = 0; i < 7; ++i) {
|
||||
dayChecks_[i] = new QCheckBox(tr(kDayLabels[i]), daysRow);
|
||||
daysLayout->addWidget(dayChecks_[i]);
|
||||
}
|
||||
|
||||
auto *stopRow = new QWidget(this);
|
||||
auto *stopLayout = new QHBoxLayout(stopRow);
|
||||
stopLayout->setContentsMargins(0, 0, 0, 0);
|
||||
stopLayout->addWidget(stopTime_);
|
||||
stopLayout->addWidget(runUntilDrains_);
|
||||
connect(runUntilDrains_, &QCheckBox::toggled, stopTime_, &QWidget::setDisabled);
|
||||
|
||||
const auto updateModeVisibility = [this, daysRow] {
|
||||
const bool periodic = mode_->currentData().toString() == QLatin1String("periodic");
|
||||
daysRow->setVisible(periodic);
|
||||
onceDate_->setVisible(!periodic);
|
||||
};
|
||||
connect(mode_, &QComboBox::currentIndexChanged, this, updateModeVisibility);
|
||||
updateModeVisibility();
|
||||
|
||||
form_ = new QWidget(this);
|
||||
auto *form = new QFormLayout(form_);
|
||||
form->addRow(useSchedule_);
|
||||
form->addRow(tr("Mode:"), mode_);
|
||||
form->addRow(tr("Start time:"), startTime_);
|
||||
form->addRow(tr("Stop time:"), stopRow);
|
||||
form->addRow(tr("Days:"), daysRow);
|
||||
form->addRow(tr("Date:"), onceDate_);
|
||||
form->addRow(tr("Max concurrent:"), maxConcurrent_);
|
||||
form->addRow(tr("When the queue finishes:"), onComplete_);
|
||||
connect(useSchedule_, &QCheckBox::toggled, this, [this](bool on) {
|
||||
for (QWidget *w :
|
||||
{static_cast<QWidget *>(mode_), static_cast<QWidget *>(startTime_),
|
||||
static_cast<QWidget *>(stopTime_), static_cast<QWidget *>(runUntilDrains_),
|
||||
static_cast<QWidget *>(onceDate_)}) {
|
||||
w->setEnabled(on);
|
||||
}
|
||||
});
|
||||
|
||||
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
||||
statusLabel_->hide();
|
||||
form_->setEnabled(false); // no queue selected yet
|
||||
|
||||
auto *startButton = new QPushButton(tr("Start Now"), this);
|
||||
auto *stopButton = new QPushButton(tr("Stop"), this);
|
||||
auto *saveButton = new QPushButton(tr("Save"), this);
|
||||
saveButton->setDefault(true);
|
||||
connect(startButton, &QPushButton::clicked, this, &SchedulerDialog::startNow);
|
||||
connect(stopButton, &QPushButton::clicked, this, &SchedulerDialog::stopNow);
|
||||
connect(saveButton, &QPushButton::clicked, this, &SchedulerDialog::save);
|
||||
|
||||
auto *actionRow = new QHBoxLayout;
|
||||
actionRow->addWidget(startButton);
|
||||
actionRow->addWidget(stopButton);
|
||||
actionRow->addWidget(pauseRunningOnStop_);
|
||||
actionRow->addStretch();
|
||||
actionRow->addWidget(saveButton);
|
||||
|
||||
auto *closeButtons = new QDialogButtonBox(QDialogButtonBox::Close, this);
|
||||
connect(closeButtons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
connect(closeButtons, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
|
||||
auto *rightLayout = new QVBoxLayout;
|
||||
rightLayout->addWidget(form_, 1);
|
||||
rightLayout->addWidget(statusLabel_);
|
||||
rightLayout->addLayout(actionRow);
|
||||
rightLayout->addWidget(closeButtons);
|
||||
|
||||
auto *split = new QHBoxLayout(this);
|
||||
split->addWidget(queueList_);
|
||||
split->addLayout(rightLayout, 1);
|
||||
|
||||
connect(queueList_, &QListWidget::currentItemChanged, this, &SchedulerDialog::onQueueSelected);
|
||||
|
||||
QPointer<SchedulerDialog> self(this);
|
||||
client_->call(QString::fromLatin1(rpc::method::kQueueList), {},
|
||||
[self](const rpc::RpcReply &reply) {
|
||||
if (!self || !reply.ok()) {
|
||||
return;
|
||||
}
|
||||
self->onQueuesLoaded(reply.result.toObject().value("items").toArray());
|
||||
});
|
||||
}
|
||||
|
||||
void SchedulerDialog::onQueuesLoaded(const QJsonArray &items) {
|
||||
queues_ = items;
|
||||
queueList_->clear();
|
||||
for (const QJsonValue &v : items) {
|
||||
auto *item = new QListWidgetItem(v.toObject().value("name").toString(), queueList_);
|
||||
item->setData(Qt::UserRole, v.toObject().value("queueId").toString());
|
||||
}
|
||||
if (queueList_->count() > 0) {
|
||||
queueList_->setCurrentRow(0);
|
||||
}
|
||||
}
|
||||
|
||||
QJsonObject SchedulerDialog::selectedQueue() const {
|
||||
if (!queueList_->currentItem()) {
|
||||
return {};
|
||||
}
|
||||
const QString id = queueList_->currentItem()->data(Qt::UserRole).toString();
|
||||
for (const QJsonValue &v : queues_) {
|
||||
if (v.toObject().value("queueId").toString() == id) {
|
||||
return v.toObject();
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void SchedulerDialog::onQueueSelected(QListWidgetItem *item, QListWidgetItem * /*previous*/) {
|
||||
if (!item) {
|
||||
form_->setEnabled(false);
|
||||
return;
|
||||
}
|
||||
populateForm(selectedQueue());
|
||||
form_->setEnabled(true);
|
||||
}
|
||||
|
||||
void SchedulerDialog::populateForm(const QJsonObject &queue) {
|
||||
maxConcurrent_->setValue(queue.value("maxConcurrent").toInt(1));
|
||||
const int completeIdx = onComplete_->findData(queue.value("onComplete").toString("nothing"));
|
||||
onComplete_->setCurrentIndex(completeIdx >= 0 ? completeIdx : 0);
|
||||
|
||||
const QJsonValue schedule = queue.value("schedule");
|
||||
const bool has = schedule.isObject();
|
||||
useSchedule_->setChecked(has);
|
||||
for (QWidget *w : {static_cast<QWidget *>(mode_), static_cast<QWidget *>(startTime_),
|
||||
static_cast<QWidget *>(stopTime_), static_cast<QWidget *>(runUntilDrains_),
|
||||
static_cast<QWidget *>(onceDate_)}) {
|
||||
w->setEnabled(has);
|
||||
}
|
||||
if (!has) {
|
||||
return;
|
||||
}
|
||||
const QJsonObject sched = schedule.toObject();
|
||||
const int modeIdx = mode_->findData(sched.value("mode").toString("once"));
|
||||
mode_->setCurrentIndex(modeIdx >= 0 ? modeIdx : 0);
|
||||
startTime_->setTime(QTime::fromString(sched.value("startTime").toString("00:00"), "HH:mm"));
|
||||
const bool stopIsNull = sched.value("stopTime").isNull();
|
||||
runUntilDrains_->setChecked(stopIsNull);
|
||||
if (!stopIsNull) {
|
||||
stopTime_->setTime(QTime::fromString(sched.value("stopTime").toString(), "HH:mm"));
|
||||
}
|
||||
for (int i = 0; i < 7; ++i) {
|
||||
dayChecks_[i]->setChecked(false);
|
||||
}
|
||||
for (const QJsonValue &d : sched.value("daysOfWeek").toArray()) {
|
||||
const int idx = d.toInt();
|
||||
if (idx >= 0 && idx < 7) {
|
||||
dayChecks_[idx]->setChecked(true);
|
||||
}
|
||||
}
|
||||
if (!sched.value("onceDate").isNull()) {
|
||||
onceDate_->setDate(QDate::fromString(sched.value("onceDate").toString(), Qt::ISODate));
|
||||
}
|
||||
}
|
||||
|
||||
void SchedulerDialog::save() {
|
||||
const QJsonObject queue = selectedQueue();
|
||||
const QString queueId = queue.value("queueId").toString();
|
||||
if (queueId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QList<int> days;
|
||||
for (int i = 0; i < 7; ++i) {
|
||||
if (dayChecks_[i]->isChecked()) {
|
||||
days << i;
|
||||
}
|
||||
}
|
||||
const QJsonValue schedule = buildSchedule(
|
||||
useSchedule_->isChecked(), mode_->currentData().toString(),
|
||||
startTime_->time().toString(QStringLiteral("HH:mm")), runUntilDrains_->isChecked(),
|
||||
stopTime_->time().toString(QStringLiteral("HH:mm")), days,
|
||||
onceDate_->date().toString(Qt::ISODate));
|
||||
|
||||
QPointer<SchedulerDialog> self(this);
|
||||
client_->call(QString::fromLatin1(rpc::method::kScheduleSet),
|
||||
QJsonObject{{"queueId", queueId}, {"schedule", schedule}},
|
||||
[self, queueId](const rpc::RpcReply &reply) {
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
if (!reply.ok()) {
|
||||
self->statusLabel_->setText(
|
||||
tr("Could not save the schedule: %1").arg(reply.error.message));
|
||||
self->statusLabel_->show();
|
||||
return;
|
||||
}
|
||||
self->statusLabel_->hide();
|
||||
});
|
||||
|
||||
// Queue-level fields (maxConcurrent/onComplete) go through queue.upsert; taskIds is
|
||||
// ignored by the daemon so re-sending the cached membership cannot drop a task.
|
||||
QJsonObject updatedQueue = queue;
|
||||
updatedQueue["maxConcurrent"] = maxConcurrent_->value();
|
||||
updatedQueue["onComplete"] = onComplete_->currentData().toString();
|
||||
client_->call(QString::fromLatin1(rpc::method::kQueueUpsert),
|
||||
QJsonObject{{"queue", updatedQueue}}, [self](const rpc::RpcReply &reply) {
|
||||
if (!self || !reply.ok()) {
|
||||
return;
|
||||
}
|
||||
const QJsonObject updated = reply.result.toObject().value("queue").toObject();
|
||||
for (int i = 0; i < self->queues_.size(); ++i) {
|
||||
if (self->queues_[i].toObject().value("queueId").toString() ==
|
||||
updated.value("queueId").toString()) {
|
||||
self->queues_[i] = updated;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void SchedulerDialog::startNow() {
|
||||
const QString queueId = selectedQueue().value("queueId").toString();
|
||||
if (queueId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
client_->call(QString::fromLatin1(rpc::method::kQueueStart), QJsonObject{{"queueId", queueId}});
|
||||
}
|
||||
|
||||
void SchedulerDialog::stopNow() {
|
||||
const QString queueId = selectedQueue().value("queueId").toString();
|
||||
if (queueId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
client_->call(
|
||||
QString::fromLatin1(rpc::method::kQueueStop),
|
||||
QJsonObject{{"queueId", queueId}, {"pauseRunning", pauseRunningOnStop_->isChecked()}});
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,73 @@
|
||||
// The Scheduler window. Lane GUI.
|
||||
//
|
||||
// docs/03-gui-spec.md §5: per-queue start/stop time, days of week, one-time vs periodic,
|
||||
// "hang up/exit when done", max concurrent per queue. Queue.schema.json already carries
|
||||
// schedule/maxConcurrent/onComplete, so queue.list alone seeds the whole window; saving
|
||||
// splits across schedule.set (the timing, its own method and event) and queue.upsert (the
|
||||
// queue-level fields queue.list doesn't route through schedule.set).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
|
||||
class QCheckBox;
|
||||
class QComboBox;
|
||||
class QDateEdit;
|
||||
class QLabel;
|
||||
class QListWidget;
|
||||
class QListWidgetItem;
|
||||
class QSpinBox;
|
||||
class QTimeEdit;
|
||||
class QWidget;
|
||||
|
||||
namespace velox::gui {
|
||||
namespace rpc {
|
||||
class RpcClient;
|
||||
} // namespace rpc
|
||||
|
||||
class SchedulerDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SchedulerDialog(rpc::RpcClient *client, QWidget *parent = nullptr);
|
||||
|
||||
/// Pure: the Schedule value to send (JSON null clears it and leaves the queue under
|
||||
/// manual control). daysOfWeek/onceDate are only meaningful for their own mode and are
|
||||
/// left out of the result when the other mode is selected, matching the schema note
|
||||
/// that daysOfWeek is "ignored when mode is 'once'".
|
||||
static QJsonValue buildSchedule(bool useSchedule, const QString &mode, const QString &startTime,
|
||||
bool runUntilDrains, const QString &stopTime,
|
||||
const QList<int> &daysOfWeek, const QString &onceDate);
|
||||
|
||||
private slots:
|
||||
void onQueuesLoaded(const QJsonArray &items);
|
||||
void onQueueSelected(QListWidgetItem *item, QListWidgetItem *previous);
|
||||
void save();
|
||||
void startNow();
|
||||
void stopNow();
|
||||
|
||||
private:
|
||||
void populateForm(const QJsonObject &queue);
|
||||
QJsonObject selectedQueue() const;
|
||||
|
||||
rpc::RpcClient *client_;
|
||||
QJsonArray queues_; // last-known-saved Queue objects, as loaded from queue.list
|
||||
|
||||
QListWidget *queueList_;
|
||||
QCheckBox *useSchedule_;
|
||||
QComboBox *mode_;
|
||||
QTimeEdit *startTime_;
|
||||
QCheckBox *runUntilDrains_;
|
||||
QTimeEdit *stopTime_;
|
||||
QCheckBox *dayChecks_[7];
|
||||
QDateEdit *onceDate_;
|
||||
QSpinBox *maxConcurrent_;
|
||||
QComboBox *onComplete_;
|
||||
QCheckBox *pauseRunningOnStop_;
|
||||
QLabel *statusLabel_;
|
||||
QWidget *form_;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,103 @@
|
||||
#include "dialogs/SpeedLimiterDialog.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QLabel>
|
||||
#include <QPointer>
|
||||
#include <QSpinBox>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
QJsonObject SpeedLimiterDialog::buildParams(bool enabled, int kibps, bool applyToRunning) {
|
||||
const qint64 bps = enabled ? static_cast<qint64>(std::max(1, kibps)) * 1024 : 0;
|
||||
return QJsonObject{
|
||||
{"enabled", enabled},
|
||||
{"globalBps", bps},
|
||||
{"applyToRunning", applyToRunning},
|
||||
};
|
||||
}
|
||||
|
||||
SpeedLimiterDialog::SpeedLimiterDialog(rpc::RpcClient *client, QWidget *parent)
|
||||
: QDialog(parent),
|
||||
client_(client),
|
||||
enabled_(new QCheckBox(tr("Limit download speed"), this)),
|
||||
kibps_(new QSpinBox(this)),
|
||||
applyToRunning_(new QCheckBox(tr("Apply to running downloads now"), this)),
|
||||
statusLabel_(new QLabel(this)) {
|
||||
setWindowTitle(tr("Speed Limiter"));
|
||||
|
||||
kibps_->setRange(1, 1000000);
|
||||
kibps_->setSuffix(tr(" KiB/s"));
|
||||
kibps_->setEnabled(false);
|
||||
connect(enabled_, &QCheckBox::toggled, kibps_, &QWidget::setEnabled);
|
||||
|
||||
statusLabel_->setStyleSheet(QStringLiteral("color: #c0392b;"));
|
||||
statusLabel_->hide();
|
||||
|
||||
auto *form = new QFormLayout;
|
||||
form->addRow(enabled_);
|
||||
form->addRow(tr("Limit to:"), kibps_);
|
||||
form->addRow(applyToRunning_);
|
||||
|
||||
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, &SpeedLimiterDialog::save);
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->addLayout(form);
|
||||
layout->addWidget(statusLabel_);
|
||||
layout->addWidget(buttons);
|
||||
|
||||
setEnabled(false);
|
||||
QPointer<SpeedLimiterDialog> self(this);
|
||||
client_->call(QString::fromLatin1(rpc::method::kLimiterGet), {},
|
||||
[self](const rpc::RpcReply &reply) {
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
self->setEnabled(true);
|
||||
if (!reply.ok()) {
|
||||
self->statusLabel_->setText(
|
||||
tr("Could not load the limiter: %1").arg(reply.error.message));
|
||||
self->statusLabel_->show();
|
||||
return;
|
||||
}
|
||||
self->onLoaded(reply.result.toObject());
|
||||
});
|
||||
}
|
||||
|
||||
void SpeedLimiterDialog::onLoaded(const QJsonObject &limiter) {
|
||||
const bool on = limiter.value("enabled").toBool();
|
||||
const qint64 bps = static_cast<qint64>(limiter.value("globalBps").toDouble());
|
||||
enabled_->setChecked(on);
|
||||
kibps_->setValue(std::max<qint64>(1, bps / 1024));
|
||||
kibps_->setEnabled(on);
|
||||
}
|
||||
|
||||
void SpeedLimiterDialog::save() {
|
||||
const QJsonObject params =
|
||||
buildParams(enabled_->isChecked(), kibps_->value(), applyToRunning_->isChecked());
|
||||
QPointer<SpeedLimiterDialog> self(this);
|
||||
client_->call(QString::fromLatin1(rpc::method::kLimiterSet), params,
|
||||
[self](const rpc::RpcReply &reply) {
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
if (!reply.ok()) {
|
||||
self->statusLabel_->setText(
|
||||
tr("Could not apply the limit: %1").arg(reply.error.message));
|
||||
self->statusLabel_->show();
|
||||
return;
|
||||
}
|
||||
self->accept();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,46 @@
|
||||
// The Speed Limiter window. Lane GUI.
|
||||
//
|
||||
// docs/03-gui-spec.md §5: off / limit to N KB/s, with an "apply to running downloads now"
|
||||
// button. Backed by limiter.get/limiter.set (types/Limiter.schema.json), not the
|
||||
// settings.* bag — this is the live global token-bucket limit, separate from Options ->
|
||||
// Downloads' speedLimitEnabled/speedLimitBps default.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <QJsonObject>
|
||||
|
||||
class QCheckBox;
|
||||
class QLabel;
|
||||
class QSpinBox;
|
||||
|
||||
namespace velox::gui {
|
||||
namespace rpc {
|
||||
class RpcClient;
|
||||
} // namespace rpc
|
||||
|
||||
class SpeedLimiterDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SpeedLimiterDialog(rpc::RpcClient *client, QWidget *parent = nullptr);
|
||||
|
||||
/// Pure: the limiter.set params for these form values. Limiter.schema.json says
|
||||
/// "0 with enabled true means stop everything, which the GUI must not offer" — kibps
|
||||
/// is clamped to at least 1 whenever enabled is true, defensively, even though the
|
||||
/// spinbox's own minimum already keeps the UI from producing 0 here.
|
||||
static QJsonObject buildParams(bool enabled, int kibps, bool applyToRunning);
|
||||
|
||||
private slots:
|
||||
void onLoaded(const QJsonObject &limiter);
|
||||
void save();
|
||||
|
||||
private:
|
||||
rpc::RpcClient *client_;
|
||||
QCheckBox *enabled_;
|
||||
QSpinBox *kibps_;
|
||||
QCheckBox *applyToRunning_;
|
||||
QLabel *statusLabel_;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <QAction>
|
||||
#include <QCloseEvent>
|
||||
#include <QCoreApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QItemSelectionModel>
|
||||
@@ -16,6 +17,7 @@
|
||||
#include <QSettings>
|
||||
#include <QSplitter>
|
||||
#include <QStatusBar>
|
||||
#include <QSystemTrayIcon>
|
||||
#include <QTimer>
|
||||
#include <QToolBar>
|
||||
#include <QTreeView>
|
||||
@@ -23,11 +25,17 @@
|
||||
#include <QWidget>
|
||||
|
||||
#include "dialogs/AddUrlDialog.hpp"
|
||||
#include "dialogs/BatchDialog.hpp"
|
||||
#include "dialogs/FileInfoDialog.hpp"
|
||||
#include "dialogs/GrabberWizard.hpp"
|
||||
#include "dialogs/OptionsDialog.hpp"
|
||||
#include "dialogs/ProgressDialog.hpp"
|
||||
#include "dialogs/SchedulerDialog.hpp"
|
||||
#include "dialogs/SpeedLimiterDialog.hpp"
|
||||
#include "mainwindow/CategoryPanel.hpp"
|
||||
#include "models/DownloadTableModel.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
#include "tray/TrayIcon.hpp"
|
||||
#include "util/Format.hpp"
|
||||
#include "widgets/ProgressDelegate.hpp"
|
||||
|
||||
@@ -102,6 +110,7 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
|
||||
buildActions();
|
||||
buildMenus();
|
||||
buildToolBar();
|
||||
buildTray();
|
||||
|
||||
// --- status bar -----------------------------------------------------------------
|
||||
connDot_->setStyleSheet(dotStyle(QStringLiteral("#c0392b")));
|
||||
@@ -123,6 +132,7 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
|
||||
&DownloadTableModel::applyTaskState);
|
||||
connect(client_, &rpc::RpcClient::taskRemoved, model_, &DownloadTableModel::applyTaskRemoved);
|
||||
connect(client_, &rpc::RpcClient::speedGlobal, this, &MainWindow::onSpeedGlobal);
|
||||
connect(client_, &rpc::RpcClient::settingsChanged, this, &MainWindow::onSettingsChanged);
|
||||
|
||||
connect(model_, &QAbstractItemModel::modelReset, this, &MainWindow::scheduleCounts);
|
||||
connect(model_, &QAbstractItemModel::rowsInserted, this, &MainWindow::scheduleCounts);
|
||||
@@ -166,6 +176,24 @@ void MainWindow::buildActions() {
|
||||
|
||||
actProperties_ = new QAction(tr("&Properties…"), this);
|
||||
connect(actProperties_, &QAction::triggered, this, &MainWindow::openPropertiesForSelection);
|
||||
|
||||
actOptions_ = new QAction(tr("&Options…"), this);
|
||||
connect(actOptions_, &QAction::triggered, this, &MainWindow::openOptionsDialog);
|
||||
|
||||
actScheduler_ = new QAction(tr("&Scheduler…"), this);
|
||||
connect(actScheduler_, &QAction::triggered, this, &MainWindow::openSchedulerDialog);
|
||||
|
||||
actSpeedLimiter_ = new QAction(tr("Speed &Limiter…"), this);
|
||||
connect(actSpeedLimiter_, &QAction::triggered, this, &MainWindow::openSpeedLimiterDialog);
|
||||
|
||||
actBatch_ = new QAction(tr("&Batch Download…"), this);
|
||||
connect(actBatch_, &QAction::triggered, this, &MainWindow::openBatchDialog);
|
||||
|
||||
actGrabber_ = new QAction(tr("Site &Grabber…"), this);
|
||||
connect(actGrabber_, &QAction::triggered, this, &MainWindow::openGrabberWizard);
|
||||
|
||||
actShow_ = new QAction(tr("&Show Velox"), this);
|
||||
connect(actShow_, &QAction::triggered, this, &MainWindow::showAndRaise);
|
||||
}
|
||||
|
||||
void MainWindow::buildMenus() {
|
||||
@@ -185,10 +213,14 @@ void MainWindow::buildMenus() {
|
||||
downloads->addAction(actResumeAll_);
|
||||
downloads->addAction(actPauseAll_);
|
||||
downloads->addSeparator();
|
||||
QAction *scheduler = downloads->addAction(tr("Scheduler…"));
|
||||
scheduler->setEnabled(false); // build order step 5
|
||||
QAction *limiter = downloads->addAction(tr("Speed Limiter…"));
|
||||
limiter->setEnabled(false);
|
||||
downloads->addAction(actScheduler_);
|
||||
downloads->addAction(actSpeedLimiter_);
|
||||
|
||||
QMenu *tools = menuBar()->addMenu(tr("&Tools"));
|
||||
tools->addAction(actBatch_);
|
||||
tools->addAction(actGrabber_);
|
||||
tools->addSeparator();
|
||||
tools->addAction(actOptions_);
|
||||
|
||||
QMenu *view = menuBar()->addMenu(tr("&View"));
|
||||
QAction *togglePanel = view->addAction(tr("&Category Panel"));
|
||||
@@ -214,9 +246,27 @@ void MainWindow::buildToolBar() {
|
||||
tb->addAction(actStop_);
|
||||
tb->addSeparator();
|
||||
tb->addAction(actRemove_);
|
||||
tb->addSeparator();
|
||||
tb->addAction(actScheduler_);
|
||||
tb->addAction(actOptions_);
|
||||
tb->addAction(actGrabber_);
|
||||
}
|
||||
|
||||
void MainWindow::buildTray() {
|
||||
if (!QSystemTrayIcon::isSystemTrayAvailable()) {
|
||||
return; // headless/mockd-only test environments, some minimal WMs
|
||||
}
|
||||
trayIcon_ = new TrayIcon(client_, actShow_, actAddUrl_, actResumeAll_, actPauseAll_, this);
|
||||
connect(trayIcon_, &TrayIcon::quitRequested, qApp, &QCoreApplication::quit);
|
||||
trayIcon_->show();
|
||||
}
|
||||
|
||||
void MainWindow::closeEvent(QCloseEvent *event) {
|
||||
if (minimizeToTrayEnabled_ && trayIcon_ && trayIcon_->isVisible()) {
|
||||
hide();
|
||||
event->ignore();
|
||||
return;
|
||||
}
|
||||
saveLayout();
|
||||
QMainWindow::closeEvent(event);
|
||||
event->accept();
|
||||
@@ -241,13 +291,15 @@ 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_,
|
||||
actAddUrl_, actProperties_}) {
|
||||
for (QAction *a :
|
||||
{actResume_, actPause_, actStop_, actRemove_, actResumeAll_, actPauseAll_, actAddUrl_,
|
||||
actProperties_, actScheduler_, actSpeedLimiter_, actBatch_, actGrabber_, actOptions_}) {
|
||||
a->setEnabled(online);
|
||||
}
|
||||
|
||||
if (online) {
|
||||
fetchTree();
|
||||
fetchMinimizeToTraySetting();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +318,29 @@ void MainWindow::fetchTree() {
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::fetchMinimizeToTraySetting() {
|
||||
client_->call(QStringLiteral("settings.get"),
|
||||
QJsonObject{{"keys", QJsonArray{QStringLiteral("general.minimizeToTray")}}},
|
||||
[this](const rpc::RpcReply &reply) {
|
||||
if (reply.ok()) {
|
||||
minimizeToTrayEnabled_ = reply.result.toObject()
|
||||
.value("values")
|
||||
.toObject()
|
||||
.value("general.minimizeToTray")
|
||||
.toBool();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::onSettingsChanged(const QJsonObject ¶ms) {
|
||||
for (const QJsonValue &key : params.value("keys").toArray()) {
|
||||
if (key.toString() == QLatin1String("general.minimizeToTray")) {
|
||||
fetchMinimizeToTraySetting();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::openAddUrlDialog() {
|
||||
AddUrlDialog dlg(this);
|
||||
if (dlg.exec() != QDialog::Accepted) {
|
||||
@@ -294,11 +369,43 @@ void MainWindow::openPropertiesForSelection() {
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::openOptionsDialog() {
|
||||
OptionsDialog dlg(client_, this);
|
||||
dlg.exec();
|
||||
}
|
||||
|
||||
void MainWindow::openSchedulerDialog() {
|
||||
SchedulerDialog dlg(client_, this);
|
||||
dlg.exec();
|
||||
}
|
||||
|
||||
void MainWindow::openSpeedLimiterDialog() {
|
||||
SpeedLimiterDialog dlg(client_, this);
|
||||
dlg.exec();
|
||||
}
|
||||
|
||||
void MainWindow::openBatchDialog() {
|
||||
BatchDialog dlg(client_, categoriesCache_, queuesCache_, this);
|
||||
dlg.exec();
|
||||
}
|
||||
|
||||
void MainWindow::openGrabberWizard() {
|
||||
auto *wizard = new GrabberWizard(client_, categoriesCache_, this);
|
||||
wizard->setAttribute(Qt::WA_DeleteOnClose);
|
||||
wizard->show();
|
||||
}
|
||||
|
||||
void MainWindow::showAndRaise() {
|
||||
show();
|
||||
raise();
|
||||
activateWindow();
|
||||
}
|
||||
|
||||
// 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.
|
||||
// need state this build order hasn't reached yet (Options' file-type associations aren't
|
||||
// per-row actions, and Batch's "Add to Queue" is a different flow than re-filing an
|
||||
// existing task) — offering them now would be a fake affordance.
|
||||
void MainWindow::showTableContextMenu(const QPoint &pos) {
|
||||
const QModelIndex idx = view_->indexAt(pos);
|
||||
if (idx.isValid() && view_->selectionModel() && !view_->selectionModel()->isSelected(idx)) {
|
||||
@@ -370,6 +477,9 @@ void MainWindow::refreshCounts() {
|
||||
}
|
||||
|
||||
panel_->setCounts(total, unfinished, finished, byCategory, byQueue);
|
||||
if (trayIcon_) {
|
||||
trayIcon_->setActiveCount(active);
|
||||
}
|
||||
|
||||
const int shown = proxy_->rowCount();
|
||||
countsLabel_->setText(tr("%1 of %2 downloads, %3 active ↓ %4")
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// The main window. Lane GUI.
|
||||
//
|
||||
// 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.
|
||||
// M1 slice: 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, a per-task progress dialog, Options/Scheduler/Speed Limiter/Batch/
|
||||
// Grabber, and the tray icon. The floating drop target and clipboard capture are still to
|
||||
// come.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -24,6 +25,7 @@ namespace velox::gui {
|
||||
|
||||
class DownloadTableModel;
|
||||
class CategoryPanel;
|
||||
class TrayIcon;
|
||||
namespace rpc {
|
||||
class RpcClient;
|
||||
} // namespace rpc
|
||||
@@ -55,12 +57,21 @@ class MainWindow : public QMainWindow {
|
||||
void openAddUrlDialog();
|
||||
void openPropertiesForSelection();
|
||||
void showTableContextMenu(const QPoint &pos);
|
||||
void openOptionsDialog();
|
||||
void openSchedulerDialog();
|
||||
void openSpeedLimiterDialog();
|
||||
void openBatchDialog();
|
||||
void openGrabberWizard();
|
||||
void showAndRaise();
|
||||
void onSettingsChanged(const QJsonObject ¶ms);
|
||||
|
||||
private:
|
||||
void buildActions();
|
||||
void buildMenus();
|
||||
void buildToolBar();
|
||||
void buildTray();
|
||||
void fetchTree();
|
||||
void fetchMinimizeToTraySetting();
|
||||
QStringList selectedTaskIds() const;
|
||||
QStringList allTaskIds() const;
|
||||
void actOnTasks(const char *methodName, const QStringList &ids);
|
||||
@@ -81,9 +92,17 @@ class MainWindow : public QMainWindow {
|
||||
QAction *actResumeAll_ = nullptr;
|
||||
QAction *actPauseAll_ = nullptr;
|
||||
QAction *actProperties_ = nullptr;
|
||||
QAction *actOptions_ = nullptr;
|
||||
QAction *actScheduler_ = nullptr;
|
||||
QAction *actSpeedLimiter_ = nullptr;
|
||||
QAction *actBatch_ = nullptr;
|
||||
QAction *actGrabber_ = nullptr;
|
||||
QAction *actShow_ = nullptr;
|
||||
|
||||
QJsonArray categoriesCache_;
|
||||
QJsonArray queuesCache_;
|
||||
TrayIcon *trayIcon_ = nullptr;
|
||||
bool minimizeToTrayEnabled_ = false;
|
||||
|
||||
QLabel *connDot_;
|
||||
QLabel *connText_;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "tray/TrayIcon.hpp"
|
||||
|
||||
#include <QAction>
|
||||
#include <QApplication>
|
||||
#include <QJsonObject>
|
||||
#include <QMenu>
|
||||
#include <QMessageBox>
|
||||
#include <QStyle>
|
||||
#include <QWidget>
|
||||
|
||||
#include "dialogs/SpeedLimiterDialog.hpp"
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
TrayIcon::TrayIcon(rpc::RpcClient *client, QAction *actShow, QAction *actAddUrl,
|
||||
QAction *actResumeAll, QAction *actPauseAll, QWidget *parent)
|
||||
: QSystemTrayIcon(parent), client_(client) {
|
||||
// No icon set has been designed yet (that's its own build-order step); a standard
|
||||
// style icon is a placeholder, not a shipped asset.
|
||||
if (auto *w = qobject_cast<QWidget *>(parent)) {
|
||||
setIcon(w->style()->standardIcon(QStyle::SP_ArrowDown));
|
||||
} else {
|
||||
setIcon(qApp->style()->standardIcon(QStyle::SP_ArrowDown));
|
||||
}
|
||||
setToolTip(QCoreApplication::translate("velox::gui::TrayIcon", "Velox Download Manager"));
|
||||
|
||||
auto *menu = new QMenu(parent);
|
||||
menu->addAction(actShow);
|
||||
menu->addSeparator();
|
||||
menu->addAction(actAddUrl);
|
||||
menu->addAction(actResumeAll);
|
||||
menu->addAction(actPauseAll);
|
||||
|
||||
auto *limiterMenu = menu->addMenu(tr("Speed Limiter"));
|
||||
limiterMenu->addAction(tr("Off"), this, [this] { setLimiterPreset(0); });
|
||||
limiterMenu->addAction(tr("128 KiB/s"), this, [this] { setLimiterPreset(128 * 1024); });
|
||||
limiterMenu->addAction(tr("512 KiB/s"), this, [this] { setLimiterPreset(512 * 1024); });
|
||||
limiterMenu->addAction(tr("2 MiB/s"), this, [this] { setLimiterPreset(2 * 1024 * 1024); });
|
||||
limiterMenu->addSeparator();
|
||||
limiterMenu->addAction(tr("Custom…"), this, &TrayIcon::openSpeedLimiterDialog);
|
||||
|
||||
menu->addSeparator();
|
||||
auto *quit = menu->addAction(tr("Quit"));
|
||||
connect(quit, &QAction::triggered, this, [this] {
|
||||
if (QMessageBox::question(
|
||||
nullptr, tr("Quit Velox"),
|
||||
tr("Quit Velox? Downloads continue in the background under the Velox "
|
||||
"service; only this window closes.")) == QMessageBox::Yes) {
|
||||
emit quitRequested();
|
||||
}
|
||||
});
|
||||
|
||||
setContextMenu(menu);
|
||||
connect(this, &QSystemTrayIcon::activated, this, [actShow](ActivationReason reason) {
|
||||
if (reason == Trigger || reason == DoubleClick) {
|
||||
actShow->trigger();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void TrayIcon::setActiveCount(int active) {
|
||||
activeCount_ = active;
|
||||
setToolTip(active > 0 ? tr("Velox — %n active download(s)", "", active) : tr("Velox — idle"));
|
||||
}
|
||||
|
||||
void TrayIcon::setLimiterPreset(qint64 bps) {
|
||||
client_->call(QString::fromLatin1(rpc::method::kLimiterSet),
|
||||
QJsonObject{
|
||||
{"enabled", bps > 0},
|
||||
{"globalBps", bps > 0 ? bps : 1},
|
||||
{"applyToRunning", true},
|
||||
});
|
||||
}
|
||||
|
||||
void TrayIcon::openSpeedLimiterDialog() {
|
||||
auto *dlg = new SpeedLimiterDialog(client_, qobject_cast<QWidget *>(parent()));
|
||||
dlg->setAttribute(Qt::WA_DeleteOnClose);
|
||||
dlg->show();
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,43 @@
|
||||
// System tray icon. Lane GUI.
|
||||
//
|
||||
// docs/03-gui-spec.md §5: active count in the tooltip, menu Show · Add URL · Pause All ·
|
||||
// Resume All · Speed limiter ▸ · Quit. Quit asks for confirmation; it cannot also offer
|
||||
// "stop the daemon" (the spec's wording) because no RPC exists to do that — filed in
|
||||
// gui/docs/daemon-requests-m1.md. Quitting the GUI never stops veloxd; downloads continue.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QSystemTrayIcon>
|
||||
|
||||
class QAction;
|
||||
|
||||
namespace velox::gui {
|
||||
namespace rpc {
|
||||
class RpcClient;
|
||||
} // namespace rpc
|
||||
|
||||
class TrayIcon : public QSystemTrayIcon {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
/// actShow is toggled/triggered to show or raise the main window; actAddUrl,
|
||||
/// actResumeAll, actPauseAll are MainWindow's own actions, added to the tray menu
|
||||
/// as-is (a QAction can live in more than one menu).
|
||||
TrayIcon(rpc::RpcClient *client, QAction *actShow, QAction *actAddUrl, QAction *actResumeAll,
|
||||
QAction *actPauseAll, QWidget *parent = nullptr);
|
||||
|
||||
public slots:
|
||||
void setActiveCount(int active);
|
||||
|
||||
signals:
|
||||
void quitRequested();
|
||||
|
||||
private:
|
||||
void setLimiterPreset(qint64 bps);
|
||||
void openSpeedLimiterDialog();
|
||||
|
||||
rpc::RpcClient *client_;
|
||||
int activeCount_ = 0;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -82,6 +82,68 @@ set_tests_properties(gui_fileinfodialog PROPERTIES
|
||||
LABELS "gui"
|
||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
|
||||
# tst_optionsdialog — OptionsDialog::diffChanged, the "only send what changed" logic.
|
||||
# Red when an unchanged key gets resent, or a key missing from `original` stops
|
||||
# counting as changed.
|
||||
add_executable(tst_optionsdialog tst_optionsdialog.cpp)
|
||||
target_compile_features(tst_optionsdialog PRIVATE cxx_std_23)
|
||||
target_compile_options(tst_optionsdialog PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(tst_optionsdialog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
|
||||
add_test(NAME gui_optionsdialog COMMAND tst_optionsdialog)
|
||||
set_tests_properties(gui_optionsdialog PROPERTIES
|
||||
LABELS "gui"
|
||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
|
||||
# tst_speedlimiterdialog — SpeedLimiterDialog::buildParams.
|
||||
# Red when disabling stops zeroing globalBps, or enabling at 0 KiB/s stops being
|
||||
# clamped to 1 (Limiter.schema.json: enabled+0 "must not be offered").
|
||||
add_executable(tst_speedlimiterdialog tst_speedlimiterdialog.cpp)
|
||||
target_compile_features(tst_speedlimiterdialog PRIVATE cxx_std_23)
|
||||
target_compile_options(tst_speedlimiterdialog PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(tst_speedlimiterdialog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
|
||||
add_test(NAME gui_speedlimiterdialog COMMAND tst_speedlimiterdialog)
|
||||
set_tests_properties(gui_speedlimiterdialog PROPERTIES
|
||||
LABELS "gui"
|
||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
|
||||
# tst_schedulerdialog — SchedulerDialog::buildSchedule.
|
||||
# Red when "use a schedule" unchecked stops sending JSON null, "run until it drains"
|
||||
# stops nulling stopTime, or daysOfWeek/onceDate leak into the wrong mode.
|
||||
add_executable(tst_schedulerdialog tst_schedulerdialog.cpp)
|
||||
target_compile_features(tst_schedulerdialog PRIVATE cxx_std_23)
|
||||
target_compile_options(tst_schedulerdialog PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(tst_schedulerdialog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
|
||||
add_test(NAME gui_schedulerdialog COMMAND tst_schedulerdialog)
|
||||
set_tests_properties(gui_schedulerdialog PROPERTIES
|
||||
LABELS "gui"
|
||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
|
||||
# tst_batchdialog — parseUrlBlob, expandWildcard, buildAddBatchParams.
|
||||
# Red when the clipboard parser stops deduping or lets a non-URL line through, the
|
||||
# wildcard expander mishandles zero-padding or an ambiguous/malformed range, or
|
||||
# addBatch params include an empty optional field or a stray queueId.
|
||||
add_executable(tst_batchdialog tst_batchdialog.cpp)
|
||||
target_compile_features(tst_batchdialog PRIVATE cxx_std_23)
|
||||
target_compile_options(tst_batchdialog PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(tst_batchdialog PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
|
||||
add_test(NAME gui_batchdialog COMMAND tst_batchdialog)
|
||||
set_tests_properties(gui_batchdialog PROPERTIES
|
||||
LABELS "gui"
|
||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
|
||||
# tst_grabberwizard — buildFileTypes, buildStartParams, buildHarvestParams.
|
||||
# Red when every file-type box unchecked stops meaning "every type" (null, not []),
|
||||
# a blank filter stops becoming schema null, or a selected fileId goes missing from
|
||||
# the harvest params.
|
||||
add_executable(tst_grabberwizard tst_grabberwizard.cpp)
|
||||
target_compile_features(tst_grabberwizard PRIVATE cxx_std_23)
|
||||
target_compile_options(tst_grabberwizard PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(tst_grabberwizard PRIVATE velox-gui-lib Qt6::Widgets Qt6::Test)
|
||||
add_test(NAME gui_grabberwizard COMMAND tst_grabberwizard)
|
||||
set_tests_properties(gui_grabberwizard PROPERTIES
|
||||
LABELS "gui"
|
||||
ENVIRONMENT "QT_QPA_PLATFORM=offscreen")
|
||||
|
||||
# gui_no_download_logic — CLAUDE.md §3 as an executable check, not a hope.
|
||||
# Red when: a download-logic token (curl, raw pwrite, sqlite, QSqlDatabase) appears
|
||||
# under gui/src. grep exits 0 only when it finds a match, so a hit fails the test.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// BatchDialog unit tests: parseUrlBlob, expandWildcard, buildAddBatchParams. Lane GUI.
|
||||
//
|
||||
// Red when the clipboard parser stops deduping or starts letting non-URL lines through,
|
||||
// the wildcard expander mishandles zero-padding or a malformed/ambiguous range, or the
|
||||
// addBatch params start including an empty optional field or leak queueId into a
|
||||
// non-"queue" startMode.
|
||||
|
||||
#include <QtTest>
|
||||
|
||||
#include "dialogs/BatchDialog.hpp"
|
||||
|
||||
using velox::gui::BatchDialog;
|
||||
|
||||
class TstBatchDialog : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void parseUrlBlobDedupesAndFiltersNonUrls();
|
||||
void parseUrlBlobTrimsAndSkipsBlankLines();
|
||||
void expandWildcardSimpleRange();
|
||||
void expandWildcardZeroPadded();
|
||||
void expandWildcardNoRangeReturnsPatternIfUrl();
|
||||
void expandWildcardMalformedRangeIsEmpty();
|
||||
void expandWildcardAmbiguousMultiRangeIsEmpty();
|
||||
void buildAddBatchParamsOmitsEmptyOptionalFields();
|
||||
void buildAddBatchParamsQueueIdOnlyForQueueMode();
|
||||
};
|
||||
|
||||
void TstBatchDialog::parseUrlBlobDedupesAndFiltersNonUrls() {
|
||||
const QString text =
|
||||
"https://example.test/a\nnot a url\nhttps://example.test/a\nhttps://example.test/b";
|
||||
const QStringList urls = BatchDialog::parseUrlBlob(text);
|
||||
QCOMPARE(urls, QStringList({"https://example.test/a", "https://example.test/b"}));
|
||||
}
|
||||
|
||||
void TstBatchDialog::parseUrlBlobTrimsAndSkipsBlankLines() {
|
||||
const QString text = " https://example.test/a \n\n\n \n";
|
||||
QCOMPARE(BatchDialog::parseUrlBlob(text), QStringList({"https://example.test/a"}));
|
||||
}
|
||||
|
||||
void TstBatchDialog::expandWildcardSimpleRange() {
|
||||
const QStringList urls = BatchDialog::expandWildcard("https://example.test/img{1..3}.jpg");
|
||||
QCOMPARE(urls, QStringList({"https://example.test/img1.jpg", "https://example.test/img2.jpg",
|
||||
"https://example.test/img3.jpg"}));
|
||||
}
|
||||
|
||||
void TstBatchDialog::expandWildcardZeroPadded() {
|
||||
const QStringList urls = BatchDialog::expandWildcard("https://example.test/img{08..10}.jpg");
|
||||
QCOMPARE(urls, QStringList({"https://example.test/img08.jpg", "https://example.test/img09.jpg",
|
||||
"https://example.test/img10.jpg"}));
|
||||
}
|
||||
|
||||
void TstBatchDialog::expandWildcardNoRangeReturnsPatternIfUrl() {
|
||||
QCOMPARE(BatchDialog::expandWildcard("https://example.test/a.jpg"),
|
||||
QStringList({"https://example.test/a.jpg"}));
|
||||
QVERIFY(BatchDialog::expandWildcard("not a url").isEmpty());
|
||||
}
|
||||
|
||||
void TstBatchDialog::expandWildcardMalformedRangeIsEmpty() {
|
||||
QVERIFY(BatchDialog::expandWildcard("https://example.test/img{5..1}.jpg").isEmpty());
|
||||
}
|
||||
|
||||
void TstBatchDialog::expandWildcardAmbiguousMultiRangeIsEmpty() {
|
||||
QVERIFY(BatchDialog::expandWildcard("https://example.test/{1..2}/img{1..3}.jpg").isEmpty());
|
||||
}
|
||||
|
||||
void TstBatchDialog::buildAddBatchParamsOmitsEmptyOptionalFields() {
|
||||
const QJsonObject params =
|
||||
BatchDialog::buildAddBatchParams({"https://example.test/a"}, QString(), QString(), "now");
|
||||
const QJsonObject defaults = params.value("defaults").toObject();
|
||||
QVERIFY(!defaults.contains("categoryId"));
|
||||
QVERIFY(!defaults.contains("queueId"));
|
||||
QCOMPARE(params.value("items").toArray().size(), 1);
|
||||
}
|
||||
|
||||
void TstBatchDialog::buildAddBatchParamsQueueIdOnlyForQueueMode() {
|
||||
const QJsonObject queued =
|
||||
BatchDialog::buildAddBatchParams({"https://example.test/a"}, QString(), "q1", "queue");
|
||||
QCOMPARE(queued.value("defaults").toObject().value("queueId").toString(), QStringLiteral("q1"));
|
||||
|
||||
const QJsonObject notQueued =
|
||||
BatchDialog::buildAddBatchParams({"https://example.test/a"}, QString(), "q1", "now");
|
||||
QVERIFY(!notQueued.value("defaults").toObject().contains("queueId"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TstBatchDialog)
|
||||
#include "tst_batchdialog.moc"
|
||||
@@ -0,0 +1,64 @@
|
||||
// GrabberWizard unit tests: buildFileTypes, buildStartParams, buildHarvestParams. Lane GUI.
|
||||
//
|
||||
// Red when every file-type checkbox unchecked stops meaning "every type" (null, not an
|
||||
// empty array), a blank include/exclude field stops becoming schema null, or harvest
|
||||
// params drop a selected fileId.
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QtTest>
|
||||
|
||||
#include "dialogs/GrabberWizard.hpp"
|
||||
|
||||
using velox::gui::GrabberWizard;
|
||||
|
||||
class TstGrabberWizard : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void noFileTypesSelectedMeansNull();
|
||||
void selectedTypesAndCustomAreMerged();
|
||||
void blankFiltersBecomeNull();
|
||||
void filtersAreSplitOnCommaAndNewline();
|
||||
void harvestParamsCarrySelection();
|
||||
};
|
||||
|
||||
void TstGrabberWizard::noFileTypesSelectedMeansNull() {
|
||||
const QJsonValue v = GrabberWizard::buildFileTypes(false, false, false, false, false, "");
|
||||
QVERIFY(v.isNull());
|
||||
}
|
||||
|
||||
void TstGrabberWizard::selectedTypesAndCustomAreMerged() {
|
||||
const QJsonValue v =
|
||||
GrabberWizard::buildFileTypes(true, false, false, false, false, "iso, bin");
|
||||
QVERIFY(v.isArray());
|
||||
const QJsonArray a = v.toArray();
|
||||
QVERIFY(a.contains(QStringLiteral("jpg")));
|
||||
QVERIFY(a.contains(QStringLiteral("iso")));
|
||||
QVERIFY(a.contains(QStringLiteral("bin")));
|
||||
}
|
||||
|
||||
void TstGrabberWizard::blankFiltersBecomeNull() {
|
||||
const QJsonObject p =
|
||||
GrabberWizard::buildStartParams("https://example.test/", 2, true, "", "", QJsonValue());
|
||||
QVERIFY(p.value("includePatterns").isNull());
|
||||
QVERIFY(p.value("excludePatterns").isNull());
|
||||
QCOMPARE(p.value("depth").toInt(), 2);
|
||||
QCOMPARE(p.value("sameHostOnly").toBool(), true);
|
||||
}
|
||||
|
||||
void TstGrabberWizard::filtersAreSplitOnCommaAndNewline() {
|
||||
const QJsonObject p = GrabberWizard::buildStartParams("https://example.test/", 1, false,
|
||||
"*.jpg,*.png\n*.gif", "", QJsonValue());
|
||||
QCOMPARE(p.value("includePatterns").toArray().size(), 3);
|
||||
}
|
||||
|
||||
void TstGrabberWizard::harvestParamsCarrySelection() {
|
||||
const QJsonObject p = GrabberWizard::buildHarvestParams("job1", {"f1", "f2"}, "cat1", "later");
|
||||
QCOMPARE(p.value("jobId").toString(), QStringLiteral("job1"));
|
||||
QCOMPARE(p.value("select").toArray().size(), 2);
|
||||
QCOMPARE(p.value("defaults").toObject().value("categoryId").toString(), QStringLiteral("cat1"));
|
||||
QCOMPARE(p.value("defaults").toObject().value("startMode").toString(), QStringLiteral("later"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TstGrabberWizard)
|
||||
#include "tst_grabberwizard.moc"
|
||||
@@ -0,0 +1,54 @@
|
||||
// OptionsDialog::diffChanged unit tests. Lane GUI.
|
||||
//
|
||||
// Red when a key that didn't actually change starts getting resent to settings.set (it
|
||||
// would still work, but it defeats the fixture's "changed[] names exactly what took
|
||||
// effect" contract and spams event.settings.changed with noise), or a key present in
|
||||
// `current` but absent from `original` stops being treated as changed.
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QtTest>
|
||||
|
||||
#include "dialogs/OptionsDialog.hpp"
|
||||
|
||||
using velox::gui::OptionsDialog;
|
||||
|
||||
class TstOptionsDialog : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void identicalValuesProduceNoDiff();
|
||||
void onlyChangedKeysAreReturned();
|
||||
void keyAbsentFromOriginalCountsAsChanged();
|
||||
void allKeysAreNonEmptyAndUnique();
|
||||
};
|
||||
|
||||
void TstOptionsDialog::identicalValuesProduceNoDiff() {
|
||||
const QJsonObject original{{"connection.timeoutSec", 30}, {"proxy.mode", "system"}};
|
||||
const QJsonObject current = original;
|
||||
QVERIFY(OptionsDialog::diffChanged(original, current).isEmpty());
|
||||
}
|
||||
|
||||
void TstOptionsDialog::onlyChangedKeysAreReturned() {
|
||||
const QJsonObject original{{"connection.timeoutSec", 30}, {"proxy.mode", "system"}};
|
||||
const QJsonObject current{{"connection.timeoutSec", 60}, {"proxy.mode", "system"}};
|
||||
const QJsonObject changed = OptionsDialog::diffChanged(original, current);
|
||||
QCOMPARE(changed.size(), 1);
|
||||
QCOMPARE(changed.value("connection.timeoutSec").toInt(), 60);
|
||||
}
|
||||
|
||||
void TstOptionsDialog::keyAbsentFromOriginalCountsAsChanged() {
|
||||
const QJsonObject original{{"proxy.mode", "system"}};
|
||||
const QJsonObject current{{"proxy.mode", "system"}, {"proxy.port", 1080}};
|
||||
const QJsonObject changed = OptionsDialog::diffChanged(original, current);
|
||||
QCOMPARE(changed.size(), 1);
|
||||
QVERIFY(changed.contains("proxy.port"));
|
||||
}
|
||||
|
||||
void TstOptionsDialog::allKeysAreNonEmptyAndUnique() {
|
||||
const QStringList keys = OptionsDialog::allKeys();
|
||||
QVERIFY(!keys.isEmpty());
|
||||
QCOMPARE(QSet<QString>(keys.begin(), keys.end()).size(), keys.size());
|
||||
}
|
||||
|
||||
QTEST_MAIN(TstOptionsDialog)
|
||||
#include "tst_optionsdialog.moc"
|
||||
@@ -0,0 +1,62 @@
|
||||
// SchedulerDialog::buildSchedule unit tests. Lane GUI.
|
||||
//
|
||||
// Red when unchecking "use a schedule" stops sending JSON null (Schedule.schema.json:
|
||||
// null clears it and leaves the queue under manual control), "run until it drains" stops
|
||||
// nulling stopTime, or daysOfWeek/onceDate leak into the mode that ignores them.
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QtTest>
|
||||
|
||||
#include "dialogs/SchedulerDialog.hpp"
|
||||
|
||||
using velox::gui::SchedulerDialog;
|
||||
|
||||
class TstSchedulerDialog : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void disabledIsNull();
|
||||
void periodicIncludesDaysNotOnceDate();
|
||||
void onceIncludesOnceDateNotDays();
|
||||
void runUntilDrainsNullsStopTime();
|
||||
void explicitStopTimeIsKept();
|
||||
};
|
||||
|
||||
void TstSchedulerDialog::disabledIsNull() {
|
||||
const QJsonValue v =
|
||||
SchedulerDialog::buildSchedule(false, "once", "09:00", true, "17:00", {}, "2026-01-01");
|
||||
QVERIFY(v.isNull());
|
||||
}
|
||||
|
||||
void TstSchedulerDialog::periodicIncludesDaysNotOnceDate() {
|
||||
const QJsonValue v = SchedulerDialog::buildSchedule(true, "periodic", "09:00", true, "17:00",
|
||||
{1, 3, 5}, "2026-01-01");
|
||||
QVERIFY(v.isObject());
|
||||
const QJsonObject s = v.toObject();
|
||||
QCOMPARE(s.value("mode").toString(), QStringLiteral("periodic"));
|
||||
QCOMPARE(s.value("daysOfWeek").toArray().size(), 3);
|
||||
QVERIFY(!s.contains("onceDate"));
|
||||
}
|
||||
|
||||
void TstSchedulerDialog::onceIncludesOnceDateNotDays() {
|
||||
const QJsonValue v = SchedulerDialog::buildSchedule(true, "once", "09:00", true, "17:00",
|
||||
{1, 3, 5}, "2026-01-01");
|
||||
const QJsonObject s = v.toObject();
|
||||
QCOMPARE(s.value("onceDate").toString(), QStringLiteral("2026-01-01"));
|
||||
QVERIFY(!s.contains("daysOfWeek"));
|
||||
}
|
||||
|
||||
void TstSchedulerDialog::runUntilDrainsNullsStopTime() {
|
||||
const QJsonValue v =
|
||||
SchedulerDialog::buildSchedule(true, "once", "09:00", true, "17:00", {}, "2026-01-01");
|
||||
QVERIFY(v.toObject().value("stopTime").isNull());
|
||||
}
|
||||
|
||||
void TstSchedulerDialog::explicitStopTimeIsKept() {
|
||||
const QJsonValue v =
|
||||
SchedulerDialog::buildSchedule(true, "once", "09:00", false, "17:00", {}, "2026-01-01");
|
||||
QCOMPARE(v.toObject().value("stopTime").toString(), QStringLiteral("17:00"));
|
||||
}
|
||||
|
||||
QTEST_MAIN(TstSchedulerDialog)
|
||||
#include "tst_schedulerdialog.moc"
|
||||
@@ -0,0 +1,46 @@
|
||||
// SpeedLimiterDialog::buildParams unit tests. Lane GUI.
|
||||
//
|
||||
// Red when disabling the limiter stops zeroing globalBps, enabling it with 0 KiB/s stops
|
||||
// being clamped to 1 (Limiter.schema.json: "0 with enabled true means stop everything,
|
||||
// which the GUI must not offer"), or the KiB/s -> bytes/s conversion drifts.
|
||||
|
||||
#include <QtTest>
|
||||
|
||||
#include "dialogs/SpeedLimiterDialog.hpp"
|
||||
|
||||
using velox::gui::SpeedLimiterDialog;
|
||||
|
||||
class TstSpeedLimiterDialog : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void disabledSendsZero();
|
||||
void enabledConvertsKibpsToBytesPerSec();
|
||||
void enabledClampsZeroToOne();
|
||||
void applyToRunningPassesThrough();
|
||||
};
|
||||
|
||||
void TstSpeedLimiterDialog::disabledSendsZero() {
|
||||
const QJsonObject p = SpeedLimiterDialog::buildParams(false, 512, false);
|
||||
QCOMPARE(p.value("enabled").toBool(), false);
|
||||
QCOMPARE(p.value("globalBps").toDouble(), 0.0);
|
||||
}
|
||||
|
||||
void TstSpeedLimiterDialog::enabledConvertsKibpsToBytesPerSec() {
|
||||
const QJsonObject p = SpeedLimiterDialog::buildParams(true, 512, false);
|
||||
QCOMPARE(p.value("enabled").toBool(), true);
|
||||
QCOMPARE(p.value("globalBps").toDouble(), 512.0 * 1024);
|
||||
}
|
||||
|
||||
void TstSpeedLimiterDialog::enabledClampsZeroToOne() {
|
||||
const QJsonObject p = SpeedLimiterDialog::buildParams(true, 0, false);
|
||||
QCOMPARE(p.value("globalBps").toDouble(), 1024.0);
|
||||
}
|
||||
|
||||
void TstSpeedLimiterDialog::applyToRunningPassesThrough() {
|
||||
QVERIFY(SpeedLimiterDialog::buildParams(true, 1, true).value("applyToRunning").toBool());
|
||||
QVERIFY(!SpeedLimiterDialog::buildParams(true, 1, false).value("applyToRunning").toBool());
|
||||
}
|
||||
|
||||
QTEST_MAIN(TstSpeedLimiterDialog)
|
||||
#include "tst_speedlimiterdialog.moc"
|
||||
Reference in New Issue
Block a user