merge: lane/gui
This commit is contained in:
@@ -22,7 +22,9 @@ add_library(velox-gui-lib STATIC
|
|||||||
src/rpc/RpcConnection.cpp
|
src/rpc/RpcConnection.cpp
|
||||||
src/rpc/RpcClient.cpp
|
src/rpc/RpcClient.cpp
|
||||||
src/models/DownloadTableModel.cpp
|
src/models/DownloadTableModel.cpp
|
||||||
|
src/models/DownloadFilterProxy.cpp
|
||||||
src/widgets/ProgressDelegate.cpp
|
src/widgets/ProgressDelegate.cpp
|
||||||
|
src/mainwindow/CategoryPanel.cpp
|
||||||
src/mainwindow/MainWindow.cpp
|
src/mainwindow/MainWindow.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#include "mainwindow/CategoryPanel.hpp"
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QTreeWidget>
|
||||||
|
#include <QTreeWidgetItem>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
namespace velox::gui {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kKindRole = Qt::UserRole; // int, a TaskSelection::Kind
|
||||||
|
constexpr int kIdRole = Qt::UserRole + 1; // QString
|
||||||
|
constexpr int kLabelRole = Qt::UserRole + 2; // QString, the label without its count suffix
|
||||||
|
|
||||||
|
TaskSelection selectionOf(const QTreeWidgetItem *item) {
|
||||||
|
TaskSelection sel;
|
||||||
|
sel.kind = static_cast<TaskSelection::Kind>(item->data(0, kKindRole).toInt());
|
||||||
|
sel.id = item->data(0, kIdRole).toString();
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
CategoryPanel::CategoryPanel(QWidget *parent) : QWidget(parent), tree_(new QTreeWidget(this)) {
|
||||||
|
tree_->setHeaderHidden(true);
|
||||||
|
tree_->setRootIsDecorated(true);
|
||||||
|
tree_->setExpandsOnDoubleClick(false);
|
||||||
|
tree_->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||||
|
|
||||||
|
auto *layout = new QVBoxLayout(this);
|
||||||
|
layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
layout->addWidget(tree_);
|
||||||
|
|
||||||
|
makeItem(nullptr, tr("All Downloads"), {TaskSelection::All, {}});
|
||||||
|
makeItem(nullptr, tr("Unfinished"), {TaskSelection::Unfinished, {}});
|
||||||
|
makeItem(nullptr, tr("Finished"), {TaskSelection::Finished, {}});
|
||||||
|
|
||||||
|
categoriesRoot_ = makeItem(nullptr, tr("Categories"), {TaskSelection::All, {}});
|
||||||
|
categoriesRoot_->setExpanded(true);
|
||||||
|
queuesRoot_ = makeItem(nullptr, tr("Queues"), {TaskSelection::All, {}});
|
||||||
|
queuesRoot_->setExpanded(true);
|
||||||
|
|
||||||
|
tree_->setCurrentItem(tree_->topLevelItem(0));
|
||||||
|
|
||||||
|
connect(tree_, &QTreeWidget::currentItemChanged, this,
|
||||||
|
[this](QTreeWidgetItem *current, QTreeWidgetItem *) { onCurrentItemChanged(current); });
|
||||||
|
}
|
||||||
|
|
||||||
|
QTreeWidgetItem *CategoryPanel::makeItem(QTreeWidgetItem *parent, const QString &label,
|
||||||
|
const TaskSelection &sel) {
|
||||||
|
auto *item = parent ? new QTreeWidgetItem(parent) : new QTreeWidgetItem(tree_);
|
||||||
|
item->setText(0, label);
|
||||||
|
item->setData(0, kLabelRole, label);
|
||||||
|
item->setData(0, kKindRole, static_cast<int>(sel.kind));
|
||||||
|
item->setData(0, kIdRole, sel.id);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CategoryPanel::onCurrentItemChanged(QTreeWidgetItem *current) {
|
||||||
|
if (current != nullptr) {
|
||||||
|
emit selectionChanged(selectionOf(current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CategoryPanel::setCategories(const QJsonArray &items) {
|
||||||
|
const TaskSelection keep =
|
||||||
|
tree_->currentItem() ? selectionOf(tree_->currentItem()) : TaskSelection{};
|
||||||
|
for (QTreeWidgetItem *child : categoriesRoot_->takeChildren()) {
|
||||||
|
delete child;
|
||||||
|
}
|
||||||
|
for (const QJsonValue &v : items) {
|
||||||
|
const QJsonObject o = v.toObject();
|
||||||
|
makeItem(categoriesRoot_, o.value("name").toString(),
|
||||||
|
{TaskSelection::Category, o.value("categoryId").toString()});
|
||||||
|
}
|
||||||
|
// Rebuilding children can drop the selection; put it back if it still exists.
|
||||||
|
if (keep.kind == TaskSelection::Category) {
|
||||||
|
for (int i = 0; i < categoriesRoot_->childCount(); ++i) {
|
||||||
|
if (selectionOf(categoriesRoot_->child(i)).id == keep.id) {
|
||||||
|
tree_->setCurrentItem(categoriesRoot_->child(i));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CategoryPanel::setQueues(const QJsonArray &items) {
|
||||||
|
const TaskSelection keep =
|
||||||
|
tree_->currentItem() ? selectionOf(tree_->currentItem()) : TaskSelection{};
|
||||||
|
for (QTreeWidgetItem *child : queuesRoot_->takeChildren()) {
|
||||||
|
delete child;
|
||||||
|
}
|
||||||
|
for (const QJsonValue &v : items) {
|
||||||
|
const QJsonObject o = v.toObject();
|
||||||
|
makeItem(queuesRoot_, o.value("name").toString(),
|
||||||
|
{TaskSelection::Queue, o.value("queueId").toString()});
|
||||||
|
}
|
||||||
|
if (keep.kind == TaskSelection::Queue) {
|
||||||
|
for (int i = 0; i < queuesRoot_->childCount(); ++i) {
|
||||||
|
if (selectionOf(queuesRoot_->child(i)).id == keep.id) {
|
||||||
|
tree_->setCurrentItem(queuesRoot_->child(i));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CategoryPanel::setCounts(int all, int unfinished, int finished,
|
||||||
|
const QHash<QString, int> &byCategory,
|
||||||
|
const QHash<QString, int> &byQueue) {
|
||||||
|
const auto render = [](QTreeWidgetItem *item, int n) {
|
||||||
|
const QString base = item->data(0, kLabelRole).toString();
|
||||||
|
item->setText(0, n > 0 ? QStringLiteral("%1 (%2)").arg(base).arg(n) : base);
|
||||||
|
};
|
||||||
|
render(tree_->topLevelItem(0), all);
|
||||||
|
render(tree_->topLevelItem(1), unfinished);
|
||||||
|
render(tree_->topLevelItem(2), finished);
|
||||||
|
for (int i = 0; i < categoriesRoot_->childCount(); ++i) {
|
||||||
|
QTreeWidgetItem *child = categoriesRoot_->child(i);
|
||||||
|
render(child, byCategory.value(selectionOf(child).id));
|
||||||
|
}
|
||||||
|
for (int i = 0; i < queuesRoot_->childCount(); ++i) {
|
||||||
|
QTreeWidgetItem *child = queuesRoot_->child(i);
|
||||||
|
render(child, byQueue.value(selectionOf(child).id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace velox::gui
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// The left-hand category tree. Lane GUI.
|
||||||
|
//
|
||||||
|
// docs/03-gui-spec.md §1: All Downloads / Unfinished / Finished, then Categories and
|
||||||
|
// Queues from category.list / queue.list. Selecting a node emits the filter the table
|
||||||
|
// should apply. Per-node counts come later (they need the full task set, not just the
|
||||||
|
// filtered page).
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "models/DownloadFilterProxy.hpp"
|
||||||
|
|
||||||
|
class QTreeWidget;
|
||||||
|
class QTreeWidgetItem;
|
||||||
|
|
||||||
|
namespace velox::gui {
|
||||||
|
|
||||||
|
class CategoryPanel : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit CategoryPanel(QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void setCategories(const QJsonArray &items);
|
||||||
|
void setQueues(const QJsonArray &items);
|
||||||
|
/// Per-node task counts, keyed by the node's filter. Missing keys render no count.
|
||||||
|
void setCounts(int all, int unfinished, int finished, const QHash<QString, int> &byCategory,
|
||||||
|
const QHash<QString, int> &byQueue);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void selectionChanged(const velox::gui::TaskSelection &selection);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QTreeWidgetItem *makeItem(QTreeWidgetItem *parent, const QString &label,
|
||||||
|
const TaskSelection &sel);
|
||||||
|
void onCurrentItemChanged(QTreeWidgetItem *current);
|
||||||
|
|
||||||
|
QTreeWidget *tree_;
|
||||||
|
QTreeWidgetItem *categoriesRoot_;
|
||||||
|
QTreeWidgetItem *queuesRoot_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace velox::gui
|
||||||
@@ -10,14 +10,19 @@
|
|||||||
#include <QKeySequence>
|
#include <QKeySequence>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLocale>
|
#include <QLocale>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QMenuBar>
|
||||||
|
#include <QMessageBox>
|
||||||
#include <QSettings>
|
#include <QSettings>
|
||||||
#include <QSortFilterProxyModel>
|
#include <QSplitter>
|
||||||
#include <QStatusBar>
|
#include <QStatusBar>
|
||||||
|
#include <QTimer>
|
||||||
#include <QToolBar>
|
#include <QToolBar>
|
||||||
#include <QTreeView>
|
#include <QTreeView>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "mainwindow/CategoryPanel.hpp"
|
||||||
#include "models/DownloadTableModel.hpp"
|
#include "models/DownloadTableModel.hpp"
|
||||||
#include "rpc/RpcClient.hpp"
|
#include "rpc/RpcClient.hpp"
|
||||||
#include "widgets/ProgressDelegate.hpp"
|
#include "widgets/ProgressDelegate.hpp"
|
||||||
@@ -43,19 +48,19 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
|
|||||||
: QMainWindow(parent),
|
: QMainWindow(parent),
|
||||||
client_(client),
|
client_(client),
|
||||||
model_(new DownloadTableModel(this)),
|
model_(new DownloadTableModel(this)),
|
||||||
proxy_(new QSortFilterProxyModel(this)),
|
proxy_(new DownloadFilterProxy(this)),
|
||||||
view_(new QTreeView(this)),
|
view_(new QTreeView(this)),
|
||||||
|
panel_(new CategoryPanel(this)),
|
||||||
connDot_(new QLabel(QStringLiteral("●"), this)),
|
connDot_(new QLabel(QStringLiteral("●"), this)),
|
||||||
connText_(new QLabel(this)),
|
connText_(new QLabel(this)),
|
||||||
countsLabel_(new QLabel(this)),
|
countsLabel_(new QLabel(this)),
|
||||||
offlineBanner_(new QWidget(this)) {
|
offlineBanner_(new QWidget(this)),
|
||||||
|
countsTimer_(new QTimer(this)) {
|
||||||
setWindowTitle(tr("Velox Download Manager"));
|
setWindowTitle(tr("Velox Download Manager"));
|
||||||
resize(960, 560);
|
resize(1040, 600);
|
||||||
|
|
||||||
proxy_->setSourceModel(model_);
|
proxy_->setSourceModel(model_);
|
||||||
proxy_->setDynamicSortFilter(true);
|
|
||||||
proxy_->setSortCaseSensitivity(Qt::CaseInsensitive);
|
proxy_->setSortCaseSensitivity(Qt::CaseInsensitive);
|
||||||
proxy_->setFilterKeyColumn(-1);
|
|
||||||
|
|
||||||
view_->setModel(proxy_);
|
view_->setModel(proxy_);
|
||||||
view_->setRootIsDecorated(false);
|
view_->setRootIsDecorated(false);
|
||||||
@@ -78,25 +83,25 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
|
|||||||
offlineBanner_->setStyleSheet(QStringLiteral("background: #5a3a00; color: #ffd9a0;"));
|
offlineBanner_->setStyleSheet(QStringLiteral("background: #5a3a00; color: #ffd9a0;"));
|
||||||
offlineBanner_->setVisible(false);
|
offlineBanner_->setVisible(false);
|
||||||
|
|
||||||
|
auto *splitter = new QSplitter(Qt::Horizontal, this);
|
||||||
|
splitter->addWidget(panel_);
|
||||||
|
splitter->addWidget(view_);
|
||||||
|
splitter->setStretchFactor(0, 0);
|
||||||
|
splitter->setStretchFactor(1, 1);
|
||||||
|
splitter->setSizes({220, 800});
|
||||||
|
splitter->setChildrenCollapsible(false);
|
||||||
|
|
||||||
auto *central = new QWidget(this);
|
auto *central = new QWidget(this);
|
||||||
auto *layout = new QVBoxLayout(central);
|
auto *layout = new QVBoxLayout(central);
|
||||||
layout->setContentsMargins(0, 0, 0, 0);
|
layout->setContentsMargins(0, 0, 0, 0);
|
||||||
layout->setSpacing(0);
|
layout->setSpacing(0);
|
||||||
layout->addWidget(offlineBanner_);
|
layout->addWidget(offlineBanner_);
|
||||||
layout->addWidget(view_);
|
layout->addWidget(splitter, 1);
|
||||||
setCentralWidget(central);
|
setCentralWidget(central);
|
||||||
|
|
||||||
// --- toolbar (dialog-free actions only for the M1 slice) -------------------------
|
buildActions();
|
||||||
auto *tb = addToolBar(tr("Main"));
|
buildMenus();
|
||||||
tb->setMovable(false);
|
buildToolBar();
|
||||||
auto *addUrl = tb->addAction(tr("Add URL"));
|
|
||||||
addUrl->setEnabled(false); // wired up with the Add URL dialog (build order step 5)
|
|
||||||
tb->addSeparator();
|
|
||||||
auto *resume = tb->addAction(tr("Resume"), this, &MainWindow::resumeSelection);
|
|
||||||
auto *pause = tb->addAction(tr("Pause"), this, &MainWindow::pauseSelection);
|
|
||||||
tb->addAction(tr("Stop"), this, &MainWindow::cancelSelection);
|
|
||||||
resume->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
|
|
||||||
pause->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_P));
|
|
||||||
|
|
||||||
// --- status bar -----------------------------------------------------------------
|
// --- status bar -----------------------------------------------------------------
|
||||||
connDot_->setStyleSheet(dotStyle(QStringLiteral("#c0392b")));
|
connDot_->setStyleSheet(dotStyle(QStringLiteral("#c0392b")));
|
||||||
@@ -104,6 +109,11 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
|
|||||||
statusBar()->addPermanentWidget(connText_);
|
statusBar()->addPermanentWidget(connText_);
|
||||||
statusBar()->addPermanentWidget(connDot_);
|
statusBar()->addPermanentWidget(connDot_);
|
||||||
|
|
||||||
|
// Counts walk every row; keep that off the 4 Hz progress path.
|
||||||
|
countsTimer_->setSingleShot(true);
|
||||||
|
countsTimer_->setInterval(400);
|
||||||
|
connect(countsTimer_, &QTimer::timeout, this, &MainWindow::refreshCounts);
|
||||||
|
|
||||||
// --- wiring --------------------------------------------------------------------
|
// --- wiring --------------------------------------------------------------------
|
||||||
connect(client_, &rpc::RpcClient::stateChanged, this, &MainWindow::onConnectionState);
|
connect(client_, &rpc::RpcClient::stateChanged, this, &MainWindow::onConnectionState);
|
||||||
connect(client_, &rpc::RpcClient::taskListReset, model_, &DownloadTableModel::resetFromJson);
|
connect(client_, &rpc::RpcClient::taskListReset, model_, &DownloadTableModel::resetFromJson);
|
||||||
@@ -114,18 +124,94 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
|
|||||||
connect(client_, &rpc::RpcClient::taskRemoved, model_, &DownloadTableModel::applyTaskRemoved);
|
connect(client_, &rpc::RpcClient::taskRemoved, model_, &DownloadTableModel::applyTaskRemoved);
|
||||||
connect(client_, &rpc::RpcClient::speedGlobal, this, &MainWindow::onSpeedGlobal);
|
connect(client_, &rpc::RpcClient::speedGlobal, this, &MainWindow::onSpeedGlobal);
|
||||||
|
|
||||||
connect(model_, &QAbstractItemModel::modelReset, this, &MainWindow::updateCounts);
|
connect(model_, &QAbstractItemModel::modelReset, this, &MainWindow::scheduleCounts);
|
||||||
connect(model_, &QAbstractItemModel::rowsInserted, this, &MainWindow::updateCounts);
|
connect(model_, &QAbstractItemModel::rowsInserted, this, &MainWindow::scheduleCounts);
|
||||||
connect(model_, &QAbstractItemModel::rowsRemoved, this, &MainWindow::updateCounts);
|
connect(model_, &QAbstractItemModel::rowsRemoved, this, &MainWindow::scheduleCounts);
|
||||||
connect(model_, &QAbstractItemModel::dataChanged, this, &MainWindow::updateCounts);
|
connect(model_, &QAbstractItemModel::dataChanged, this, &MainWindow::scheduleCounts);
|
||||||
|
|
||||||
|
connect(panel_, &CategoryPanel::selectionChanged, this, &MainWindow::onFilterChanged);
|
||||||
|
|
||||||
restoreLayout();
|
restoreLayout();
|
||||||
onConnectionState(client_->state());
|
onConnectionState(client_->state());
|
||||||
updateCounts();
|
refreshCounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
MainWindow::~MainWindow() = default;
|
MainWindow::~MainWindow() = default;
|
||||||
|
|
||||||
|
void MainWindow::buildActions() {
|
||||||
|
actAddUrl_ = new QAction(tr("Add &URL…"), this);
|
||||||
|
actAddUrl_->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_N));
|
||||||
|
actAddUrl_->setEnabled(false); // wired up with the Add URL dialog (build order step 5)
|
||||||
|
|
||||||
|
actResume_ = new QAction(tr("&Resume"), this);
|
||||||
|
actResume_->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
|
||||||
|
connect(actResume_, &QAction::triggered, this, &MainWindow::resumeSelection);
|
||||||
|
|
||||||
|
actPause_ = new QAction(tr("&Pause"), this);
|
||||||
|
actPause_->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_P));
|
||||||
|
connect(actPause_, &QAction::triggered, this, &MainWindow::pauseSelection);
|
||||||
|
|
||||||
|
actStop_ = new QAction(tr("&Stop"), this);
|
||||||
|
connect(actStop_, &QAction::triggered, this, &MainWindow::cancelSelection);
|
||||||
|
|
||||||
|
actRemove_ = new QAction(tr("&Delete"), this);
|
||||||
|
actRemove_->setShortcut(QKeySequence::Delete);
|
||||||
|
connect(actRemove_, &QAction::triggered, this, &MainWindow::removeSelection);
|
||||||
|
|
||||||
|
actResumeAll_ = new QAction(tr("Resume A&ll"), this);
|
||||||
|
connect(actResumeAll_, &QAction::triggered, this, &MainWindow::resumeAll);
|
||||||
|
|
||||||
|
actPauseAll_ = new QAction(tr("Pause All"), this);
|
||||||
|
connect(actPauseAll_, &QAction::triggered, this, &MainWindow::pauseAll);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::buildMenus() {
|
||||||
|
QMenu *tasks = menuBar()->addMenu(tr("&Tasks"));
|
||||||
|
tasks->addAction(actAddUrl_);
|
||||||
|
tasks->addSeparator();
|
||||||
|
tasks->addAction(actResume_);
|
||||||
|
tasks->addAction(actPause_);
|
||||||
|
tasks->addAction(actStop_);
|
||||||
|
tasks->addSeparator();
|
||||||
|
tasks->addAction(actRemove_);
|
||||||
|
tasks->addSeparator();
|
||||||
|
tasks->addAction(tr("E&xit"), QKeySequence::Quit, this, &QWidget::close);
|
||||||
|
|
||||||
|
QMenu *downloads = menuBar()->addMenu(tr("&Downloads"));
|
||||||
|
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);
|
||||||
|
|
||||||
|
QMenu *view = menuBar()->addMenu(tr("&View"));
|
||||||
|
QAction *togglePanel = view->addAction(tr("&Category Panel"));
|
||||||
|
togglePanel->setCheckable(true);
|
||||||
|
togglePanel->setChecked(true);
|
||||||
|
connect(togglePanel, &QAction::toggled, panel_, &QWidget::setVisible);
|
||||||
|
|
||||||
|
QMenu *help = menuBar()->addMenu(tr("&Help"));
|
||||||
|
help->addAction(tr("&About Velox"), this, [this] {
|
||||||
|
QMessageBox::about(this, tr("About Velox"),
|
||||||
|
tr("Velox Download Manager — GUI client (M1)."));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::buildToolBar() {
|
||||||
|
auto *tb = addToolBar(tr("Main"));
|
||||||
|
tb->setObjectName(QStringLiteral("mainToolBar"));
|
||||||
|
tb->setMovable(false);
|
||||||
|
tb->addAction(actAddUrl_);
|
||||||
|
tb->addSeparator();
|
||||||
|
tb->addAction(actResume_);
|
||||||
|
tb->addAction(actPause_);
|
||||||
|
tb->addAction(actStop_);
|
||||||
|
tb->addSeparator();
|
||||||
|
tb->addAction(actRemove_);
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::closeEvent(QCloseEvent *event) {
|
void MainWindow::closeEvent(QCloseEvent *event) {
|
||||||
saveLayout();
|
saveLayout();
|
||||||
QMainWindow::closeEvent(event);
|
QMainWindow::closeEvent(event);
|
||||||
@@ -151,24 +237,85 @@ void MainWindow::onConnectionState(rpc::ConnectionState state) {
|
|||||||
: tr("The Velox service is not running. Downloads are unaffected; "
|
: tr("The Velox service is not running. Downloads are unaffected; "
|
||||||
"this list is read-only until it returns."));
|
"this list is read-only until it returns."));
|
||||||
}
|
}
|
||||||
|
for (QAction *a : {actResume_, actPause_, actStop_, actRemove_, actResumeAll_, actPauseAll_}) {
|
||||||
|
a->setEnabled(online);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (online) {
|
||||||
|
fetchTree();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::fetchTree() {
|
||||||
|
client_->call(QStringLiteral("category.list"), {}, [this](const rpc::RpcReply &reply) {
|
||||||
|
if (reply.ok()) {
|
||||||
|
panel_->setCategories(reply.result.toObject().value("items").toArray());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
client_->call(QStringLiteral("queue.list"), {}, [this](const rpc::RpcReply &reply) {
|
||||||
|
if (reply.ok()) {
|
||||||
|
panel_->setQueues(reply.result.toObject().value("items").toArray());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::onFilterChanged(const TaskSelection &selection) {
|
||||||
|
proxy_->setSelection(selection);
|
||||||
|
// TODO(gui): also push proxy_->asTaskFilter() to a fresh download.list once the daemon
|
||||||
|
// is serving 100k-row lists and the client should not hold them all. For M1 the whole
|
||||||
|
// list fits and the proxy is the single source of what's shown.
|
||||||
|
refreshCounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::onSpeedGlobal(const QJsonObject ¶ms) {
|
void MainWindow::onSpeedGlobal(const QJsonObject ¶ms) {
|
||||||
globalDownBps_ = static_cast<qint64>(params.value("downBps").toDouble());
|
globalDownBps_ = static_cast<qint64>(params.value("downBps").toDouble());
|
||||||
updateCounts();
|
scheduleCounts();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::updateCounts() {
|
void MainWindow::scheduleCounts() {
|
||||||
|
// Throttle, not debounce: a restart-on-every-event timer never fires while progress
|
||||||
|
// ticks arrive faster than its interval. Start it only when it is not already pending.
|
||||||
|
if (!countsTimer_->isActive()) {
|
||||||
|
countsTimer_->start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::refreshCounts() {
|
||||||
const int total = model_->rowCount();
|
const int total = model_->rowCount();
|
||||||
int active = 0;
|
int active = 0;
|
||||||
|
int unfinished = 0;
|
||||||
|
int finished = 0;
|
||||||
|
QHash<QString, int> byCategory;
|
||||||
|
QHash<QString, int> byQueue;
|
||||||
for (int i = 0; i < total; ++i) {
|
for (int i = 0; i < total; ++i) {
|
||||||
if (model_->index(i, 0).data(DownloadTableModel::StateRole).toString() ==
|
const QModelIndex idx = model_->index(i, 0);
|
||||||
QLatin1String("downloading")) {
|
const QString state = idx.data(DownloadTableModel::StateRole).toString();
|
||||||
|
if (state == QLatin1String("downloading")) {
|
||||||
++active;
|
++active;
|
||||||
}
|
}
|
||||||
|
if (state == QLatin1String("complete")) {
|
||||||
|
++finished;
|
||||||
|
} else {
|
||||||
|
++unfinished;
|
||||||
|
}
|
||||||
|
const QString cat = idx.data(DownloadTableModel::CategoryIdRole).toString();
|
||||||
|
if (!cat.isEmpty()) {
|
||||||
|
++byCategory[cat];
|
||||||
|
}
|
||||||
|
const QString queue = idx.data(DownloadTableModel::QueueIdRole).toString();
|
||||||
|
if (!queue.isEmpty()) {
|
||||||
|
++byQueue[queue];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
countsLabel_->setText(
|
|
||||||
tr("%1 downloads, %2 active ↓ %3").arg(total).arg(active).arg(humanRate(globalDownBps_)));
|
panel_->setCounts(total, unfinished, finished, byCategory, byQueue);
|
||||||
|
|
||||||
|
const int shown = proxy_->rowCount();
|
||||||
|
countsLabel_->setText(tr("%1 of %2 downloads, %3 active ↓ %4")
|
||||||
|
.arg(shown)
|
||||||
|
.arg(total)
|
||||||
|
.arg(active)
|
||||||
|
.arg(humanRate(globalDownBps_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
QStringList MainWindow::selectedTaskIds() const {
|
QStringList MainWindow::selectedTaskIds() const {
|
||||||
@@ -177,8 +324,8 @@ QStringList MainWindow::selectedTaskIds() const {
|
|||||||
view_->selectionModel() ? view_->selectionModel()->selectedRows() : QModelIndexList{};
|
view_->selectionModel() ? view_->selectionModel()->selectedRows() : QModelIndexList{};
|
||||||
ids.reserve(rows.size());
|
ids.reserve(rows.size());
|
||||||
for (const QModelIndex &proxyIdx : rows) {
|
for (const QModelIndex &proxyIdx : rows) {
|
||||||
const QModelIndex src = proxy_->mapToSource(proxyIdx);
|
const QString id =
|
||||||
const QString id = model_->data(src, DownloadTableModel::TaskIdRole).toString();
|
proxy_->mapToSource(proxyIdx).data(DownloadTableModel::TaskIdRole).toString();
|
||||||
if (!id.isEmpty()) {
|
if (!id.isEmpty()) {
|
||||||
ids << id;
|
ids << id;
|
||||||
}
|
}
|
||||||
@@ -186,8 +333,17 @@ QStringList MainWindow::selectedTaskIds() const {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::actOnSelection(const char *methodName) {
|
QStringList MainWindow::allTaskIds() const {
|
||||||
const QStringList ids = selectedTaskIds();
|
QStringList ids;
|
||||||
|
const int n = model_->rowCount();
|
||||||
|
ids.reserve(n);
|
||||||
|
for (int i = 0; i < n; ++i) {
|
||||||
|
ids << model_->index(i, 0).data(DownloadTableModel::TaskIdRole).toString();
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::actOnTasks(const char *methodName, const QStringList &ids) {
|
||||||
if (ids.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -196,13 +352,33 @@ void MainWindow::actOnSelection(const char *methodName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::pauseSelection() {
|
void MainWindow::pauseSelection() {
|
||||||
actOnSelection(rpc::method::kDownloadPause);
|
actOnTasks(rpc::method::kDownloadPause, selectedTaskIds());
|
||||||
}
|
}
|
||||||
void MainWindow::resumeSelection() {
|
void MainWindow::resumeSelection() {
|
||||||
actOnSelection(rpc::method::kDownloadResume);
|
actOnTasks(rpc::method::kDownloadResume, selectedTaskIds());
|
||||||
}
|
}
|
||||||
void MainWindow::cancelSelection() {
|
void MainWindow::cancelSelection() {
|
||||||
actOnSelection(rpc::method::kDownloadCancel);
|
actOnTasks(rpc::method::kDownloadCancel, selectedTaskIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::removeSelection() {
|
||||||
|
const QStringList ids = selectedTaskIds();
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto answer = QMessageBox::question(
|
||||||
|
this, tr("Delete downloads"),
|
||||||
|
tr("Remove %n download(s) from the list? The partial data is kept.", "", ids.size()));
|
||||||
|
if (answer == QMessageBox::Yes) {
|
||||||
|
actOnTasks(rpc::method::kDownloadRemove, ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::resumeAll() {
|
||||||
|
actOnTasks(rpc::method::kDownloadResume, allTaskIds());
|
||||||
|
}
|
||||||
|
void MainWindow::pauseAll() {
|
||||||
|
actOnTasks(rpc::method::kDownloadPause, allTaskIds());
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::restoreLayout() {
|
void MainWindow::restoreLayout() {
|
||||||
@@ -215,7 +391,17 @@ void MainWindow::restoreLayout() {
|
|||||||
settings.value(QStringLiteral("mainwindow/headerState")).toByteArray();
|
settings.value(QStringLiteral("mainwindow/headerState")).toByteArray();
|
||||||
if (!header.isEmpty()) {
|
if (!header.isEmpty()) {
|
||||||
view_->header()->restoreState(header);
|
view_->header()->restoreState(header);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// First run: widths that fit the content rather than an even split. Description is the
|
||||||
|
// stretch column, so it takes the remainder.
|
||||||
|
view_->setColumnWidth(DownloadTableModel::ColName, 280);
|
||||||
|
view_->setColumnWidth(DownloadTableModel::ColQueue, 32);
|
||||||
|
view_->setColumnWidth(DownloadTableModel::ColSize, 90);
|
||||||
|
view_->setColumnWidth(DownloadTableModel::ColStatus, 130);
|
||||||
|
view_->setColumnWidth(DownloadTableModel::ColTimeLeft, 80);
|
||||||
|
view_->setColumnWidth(DownloadTableModel::ColSpeed, 90);
|
||||||
|
view_->setColumnWidth(DownloadTableModel::ColLastTry, 130);
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::saveLayout() {
|
void MainWindow::saveLayout() {
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
// The main window. Lane GUI.
|
// The main window. Lane GUI.
|
||||||
//
|
//
|
||||||
// M1 slice: category tree, dialogs and the tray come later. This is the table driven by
|
// M1 slice: dialogs and the tray still come later. This is the menus + toolbar + splitter
|
||||||
// mockd, a status-bar connection dot, an offline banner instead of a modal, and the three
|
// (category tree | table), the table driven by mockd, a status-bar connection dot, an
|
||||||
// selection actions that need no dialog (pause / resume / cancel).
|
// offline banner instead of a modal, and the actions that need no dialog.
|
||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
|
|
||||||
|
#include "models/DownloadFilterProxy.hpp"
|
||||||
#include "rpc/Protocol.hpp"
|
#include "rpc/Protocol.hpp"
|
||||||
|
|
||||||
|
class QAction;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
class QSortFilterProxyModel;
|
class QTimer;
|
||||||
class QTreeView;
|
class QTreeView;
|
||||||
|
|
||||||
namespace velox::gui {
|
namespace velox::gui {
|
||||||
|
|
||||||
class DownloadTableModel;
|
class DownloadTableModel;
|
||||||
|
class CategoryPanel;
|
||||||
namespace rpc {
|
namespace rpc {
|
||||||
class RpcClient;
|
class RpcClient;
|
||||||
} // namespace rpc
|
} // namespace rpc
|
||||||
@@ -34,27 +38,47 @@ class MainWindow : public QMainWindow {
|
|||||||
private slots:
|
private slots:
|
||||||
void onConnectionState(rpc::ConnectionState state);
|
void onConnectionState(rpc::ConnectionState state);
|
||||||
void onSpeedGlobal(const QJsonObject ¶ms);
|
void onSpeedGlobal(const QJsonObject ¶ms);
|
||||||
void updateCounts();
|
void onFilterChanged(const velox::gui::TaskSelection &selection);
|
||||||
|
void scheduleCounts(); // throttled: fires refreshCounts() at most every 400 ms
|
||||||
|
void refreshCounts();
|
||||||
|
|
||||||
void pauseSelection();
|
void pauseSelection();
|
||||||
void resumeSelection();
|
void resumeSelection();
|
||||||
void cancelSelection();
|
void cancelSelection();
|
||||||
|
void removeSelection();
|
||||||
|
void resumeAll();
|
||||||
|
void pauseAll();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void buildActions();
|
||||||
|
void buildMenus();
|
||||||
|
void buildToolBar();
|
||||||
|
void fetchTree();
|
||||||
QStringList selectedTaskIds() const;
|
QStringList selectedTaskIds() const;
|
||||||
void actOnSelection(const char *methodName);
|
QStringList allTaskIds() const;
|
||||||
|
void actOnTasks(const char *methodName, const QStringList &ids);
|
||||||
void restoreLayout();
|
void restoreLayout();
|
||||||
void saveLayout();
|
void saveLayout();
|
||||||
|
|
||||||
rpc::RpcClient *client_;
|
rpc::RpcClient *client_;
|
||||||
DownloadTableModel *model_;
|
DownloadTableModel *model_;
|
||||||
QSortFilterProxyModel *proxy_;
|
DownloadFilterProxy *proxy_;
|
||||||
QTreeView *view_;
|
QTreeView *view_;
|
||||||
|
CategoryPanel *panel_;
|
||||||
|
|
||||||
|
QAction *actAddUrl_ = nullptr;
|
||||||
|
QAction *actResume_ = nullptr;
|
||||||
|
QAction *actPause_ = nullptr;
|
||||||
|
QAction *actStop_ = nullptr;
|
||||||
|
QAction *actRemove_ = nullptr;
|
||||||
|
QAction *actResumeAll_ = nullptr;
|
||||||
|
QAction *actPauseAll_ = nullptr;
|
||||||
|
|
||||||
QLabel *connDot_;
|
QLabel *connDot_;
|
||||||
QLabel *connText_;
|
QLabel *connText_;
|
||||||
QLabel *countsLabel_;
|
QLabel *countsLabel_;
|
||||||
QWidget *offlineBanner_;
|
QWidget *offlineBanner_;
|
||||||
|
QTimer *countsTimer_;
|
||||||
|
|
||||||
qint64 globalDownBps_ = 0;
|
qint64 globalDownBps_ = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#include "models/DownloadFilterProxy.hpp"
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
#include "models/DownloadTableModel.hpp"
|
||||||
|
|
||||||
|
namespace velox::gui {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// "Finished" is the completed pile; everything else — active, paused, queued, errored,
|
||||||
|
// cancelled — is "Unfinished", matching IDM's two top-level nodes.
|
||||||
|
bool isFinished(const QString &state) {
|
||||||
|
return state == QLatin1String("complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
DownloadFilterProxy::DownloadFilterProxy(QObject *parent) : QSortFilterProxyModel(parent) {
|
||||||
|
setDynamicSortFilter(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DownloadFilterProxy::setSelection(const TaskSelection &selection) {
|
||||||
|
if (selection_ == selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selection_ = selection;
|
||||||
|
beginFilterChange();
|
||||||
|
endFilterChange(QSortFilterProxyModel::Direction::Rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DownloadFilterProxy::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const {
|
||||||
|
if (selection_.kind == TaskSelection::All) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const QModelIndex idx = sourceModel()->index(sourceRow, 0, sourceParent);
|
||||||
|
switch (selection_.kind) {
|
||||||
|
case TaskSelection::All:
|
||||||
|
return true;
|
||||||
|
case TaskSelection::Unfinished:
|
||||||
|
return !isFinished(idx.data(DownloadTableModel::StateRole).toString());
|
||||||
|
case TaskSelection::Finished:
|
||||||
|
return isFinished(idx.data(DownloadTableModel::StateRole).toString());
|
||||||
|
case TaskSelection::Category:
|
||||||
|
return idx.data(DownloadTableModel::CategoryIdRole).toString() == selection_.id;
|
||||||
|
case TaskSelection::Queue:
|
||||||
|
return idx.data(DownloadTableModel::QueueIdRole).toString() == selection_.id;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject DownloadFilterProxy::asTaskFilter() const {
|
||||||
|
switch (selection_.kind) {
|
||||||
|
case TaskSelection::All:
|
||||||
|
return {};
|
||||||
|
case TaskSelection::Finished:
|
||||||
|
return QJsonObject{{"states", QJsonArray{"complete"}}};
|
||||||
|
case TaskSelection::Unfinished:
|
||||||
|
return QJsonObject{
|
||||||
|
{"states",
|
||||||
|
QJsonArray{"new", "probing", "queued", "connecting", "downloading", "paused",
|
||||||
|
"retry_wait", "assembling", "verifying", "failed", "cancelled"}}};
|
||||||
|
case TaskSelection::Category:
|
||||||
|
return QJsonObject{{"categoryId", selection_.id}};
|
||||||
|
case TaskSelection::Queue:
|
||||||
|
return QJsonObject{{"queueId", selection_.id}};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace velox::gui
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Category-tree filtering for the download table. Lane GUI.
|
||||||
|
//
|
||||||
|
// docs/03-gui-spec.md §1: the left tree selects which rows the table shows. The daemon
|
||||||
|
// does the heavy filtering for a 100k-row list; this proxy is the client-side view on top
|
||||||
|
// of whatever download.list returned, so a task that changes state or category mid-view
|
||||||
|
// leaves the filtered set immediately rather than lingering until the next re-list.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QSortFilterProxyModel>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
namespace velox::gui {
|
||||||
|
|
||||||
|
/// One node of the category tree, expressed as a filter.
|
||||||
|
struct TaskSelection {
|
||||||
|
enum Kind { All, Unfinished, Finished, Category, Queue };
|
||||||
|
Kind kind = All;
|
||||||
|
QString id; // categoryId or queueId, when kind is Category or Queue
|
||||||
|
|
||||||
|
bool operator==(const TaskSelection &) const = default;
|
||||||
|
};
|
||||||
|
|
||||||
|
class DownloadFilterProxy : public QSortFilterProxyModel {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit DownloadFilterProxy(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
void setSelection(const TaskSelection &selection);
|
||||||
|
TaskSelection selection() const { return selection_; }
|
||||||
|
|
||||||
|
/// Equivalent TaskFilter params for a server-side download.list, so a caller can push
|
||||||
|
/// the same filter to the daemon when paging matters. Empty object for `All`.
|
||||||
|
QJsonObject asTaskFilter() const;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
TaskSelection selection_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace velox::gui
|
||||||
@@ -89,6 +89,10 @@ QVariant DownloadTableModel::data(const QModelIndex &index, int role) const {
|
|||||||
return r.taskId;
|
return r.taskId;
|
||||||
case StateRole:
|
case StateRole:
|
||||||
return r.state;
|
return r.state;
|
||||||
|
case CategoryIdRole:
|
||||||
|
return r.categoryId;
|
||||||
|
case QueueIdRole:
|
||||||
|
return r.queueId;
|
||||||
case ProgressRole:
|
case ProgressRole:
|
||||||
return progressOf(r);
|
return progressOf(r);
|
||||||
case Qt::TextAlignmentRole:
|
case Qt::TextAlignmentRole:
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ class DownloadTableModel : public QAbstractTableModel {
|
|||||||
enum Roles {
|
enum Roles {
|
||||||
ProgressRole = Qt::UserRole + 1, ///< double in [0,1], -1 when the size is unknown
|
ProgressRole = Qt::UserRole + 1, ///< double in [0,1], -1 when the size is unknown
|
||||||
TaskIdRole,
|
TaskIdRole,
|
||||||
StateRole, ///< the TaskState as a lowercase string
|
StateRole, ///< the TaskState as a lowercase string
|
||||||
|
CategoryIdRole, ///< categoryId, or empty
|
||||||
|
QueueIdRole, ///< queueId, or empty
|
||||||
};
|
};
|
||||||
|
|
||||||
explicit DownloadTableModel(QObject *parent = nullptr);
|
explicit DownloadTableModel(QObject *parent = nullptr);
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ target_link_libraries(tst_downloadtablemodel PRIVATE Qt6::Core Qt6::Test)
|
|||||||
add_test(NAME gui_downloadtablemodel COMMAND tst_downloadtablemodel)
|
add_test(NAME gui_downloadtablemodel COMMAND tst_downloadtablemodel)
|
||||||
set_tests_properties(gui_downloadtablemodel PROPERTIES LABELS "gui")
|
set_tests_properties(gui_downloadtablemodel PROPERTIES LABELS "gui")
|
||||||
|
|
||||||
|
# tst_downloadfilterproxy — the category-tree filter.
|
||||||
|
# Red when: a node stops filtering to its own rows, the Finished/Unfinished split
|
||||||
|
# misclassifies a state, or the filter stops being dynamic.
|
||||||
|
add_executable(tst_downloadfilterproxy tst_downloadfilterproxy.cpp)
|
||||||
|
target_compile_features(tst_downloadfilterproxy PRIVATE cxx_std_23)
|
||||||
|
target_compile_options(tst_downloadfilterproxy PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||||
|
target_link_libraries(tst_downloadfilterproxy PRIVATE velox-gui-lib Qt6::Test)
|
||||||
|
add_test(NAME gui_downloadfilterproxy COMMAND tst_downloadfilterproxy)
|
||||||
|
set_tests_properties(gui_downloadfilterproxy PROPERTIES LABELS "gui")
|
||||||
|
|
||||||
# tst_rtl — the real RTL check the GUI DoD asks for (the .ts stub alone verifies nothing).
|
# tst_rtl — the real RTL check the GUI DoD asks for (the .ts stub alone verifies nothing).
|
||||||
# Red when: the main window stops propagating Qt::RightToLeft to its children, or the
|
# Red when: the main window stops propagating Qt::RightToLeft to its children, or the
|
||||||
# offline-banner layout is pinned so it does not mirror under RTL.
|
# offline-banner layout is pinned so it does not mirror under RTL.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// DownloadFilterProxy unit tests. Lane GUI.
|
||||||
|
//
|
||||||
|
// Red when: a category-tree node stops filtering the table to its own rows, the
|
||||||
|
// Finished/Unfinished split misclassifies a state, or the filter stops being dynamic (a
|
||||||
|
// row that changes state mid-view lingers in the wrong node).
|
||||||
|
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QtTest>
|
||||||
|
|
||||||
|
#include "models/DownloadFilterProxy.hpp"
|
||||||
|
#include "models/DownloadTableModel.hpp"
|
||||||
|
|
||||||
|
using velox::gui::DownloadFilterProxy;
|
||||||
|
using velox::gui::DownloadTableModel;
|
||||||
|
using velox::gui::TaskSelection;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QJsonObject task(const QString &id, const QString &state, const QString &category,
|
||||||
|
const QString &queue) {
|
||||||
|
return QJsonObject{
|
||||||
|
{"taskId", id},
|
||||||
|
{"filename", id + ".bin"},
|
||||||
|
{"url", "https://example.test/" + id},
|
||||||
|
{"state", state},
|
||||||
|
{"sizeBytes", 1000.0},
|
||||||
|
{"downloadedBytes", state == QLatin1String("complete") ? 1000.0 : 100.0},
|
||||||
|
{"categoryId", category},
|
||||||
|
{"queueId", queue},
|
||||||
|
{"createdAt", "2026-09-10T00:00:00Z"},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray sample() {
|
||||||
|
return QJsonArray{
|
||||||
|
task("a", "downloading", "music", "main"), task("b", "complete", "music", ""),
|
||||||
|
task("c", "paused", "video", "main"), task("d", "complete", "video", ""),
|
||||||
|
task("e", "queued", "programs", "sync"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
class TstDownloadFilterProxy : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void nodesFilterToTheirRows();
|
||||||
|
void filterIsDynamic();
|
||||||
|
};
|
||||||
|
|
||||||
|
void TstDownloadFilterProxy::nodesFilterToTheirRows() {
|
||||||
|
DownloadTableModel model;
|
||||||
|
model.resetFromJson(sample());
|
||||||
|
DownloadFilterProxy proxy;
|
||||||
|
proxy.setSourceModel(&model);
|
||||||
|
|
||||||
|
proxy.setSelection({TaskSelection::All, {}});
|
||||||
|
QCOMPARE(proxy.rowCount(), 5);
|
||||||
|
|
||||||
|
proxy.setSelection({TaskSelection::Finished, {}});
|
||||||
|
QCOMPARE(proxy.rowCount(), 2); // b, d
|
||||||
|
|
||||||
|
proxy.setSelection({TaskSelection::Unfinished, {}});
|
||||||
|
QCOMPARE(proxy.rowCount(), 3); // a, c, e
|
||||||
|
|
||||||
|
proxy.setSelection({TaskSelection::Category, QStringLiteral("music")});
|
||||||
|
QCOMPARE(proxy.rowCount(), 2); // a, b
|
||||||
|
|
||||||
|
proxy.setSelection({TaskSelection::Queue, QStringLiteral("main")});
|
||||||
|
QCOMPARE(proxy.rowCount(), 2); // a, c
|
||||||
|
|
||||||
|
proxy.setSelection({TaskSelection::Category, QStringLiteral("nope")});
|
||||||
|
QCOMPARE(proxy.rowCount(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TstDownloadFilterProxy::filterIsDynamic() {
|
||||||
|
DownloadTableModel model;
|
||||||
|
model.resetFromJson(sample());
|
||||||
|
DownloadFilterProxy proxy;
|
||||||
|
proxy.setSourceModel(&model);
|
||||||
|
proxy.setSelection({TaskSelection::Unfinished, {}});
|
||||||
|
QCOMPARE(proxy.rowCount(), 3);
|
||||||
|
|
||||||
|
// "a" finishes — it must leave the Unfinished node without a re-list.
|
||||||
|
model.applyTaskState(
|
||||||
|
QJsonObject{{"taskId", "a"}, {"summary", task("a", "complete", "music", "main")}});
|
||||||
|
QCOMPARE(proxy.rowCount(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
QTEST_GUILESS_MAIN(TstDownloadFilterProxy)
|
||||||
|
#include "tst_downloadfilterproxy.moc"
|
||||||
Reference in New Issue
Block a user