merge: lane/gui

This commit is contained in:
2026-09-10 18:55:19 +04:00
11 changed files with 655 additions and 44 deletions
+2
View File
@@ -22,7 +22,9 @@ add_library(velox-gui-lib STATIC
src/rpc/RpcConnection.cpp
src/rpc/RpcClient.cpp
src/models/DownloadTableModel.cpp
src/models/DownloadFilterProxy.cpp
src/widgets/ProgressDelegate.cpp
src/mainwindow/CategoryPanel.cpp
src/mainwindow/MainWindow.cpp
)
+128
View File
@@ -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
+47
View File
@@ -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
+222 -36
View File
@@ -10,14 +10,19 @@
#include <QKeySequence>
#include <QLabel>
#include <QLocale>
#include <QMenu>
#include <QMenuBar>
#include <QMessageBox>
#include <QSettings>
#include <QSortFilterProxyModel>
#include <QSplitter>
#include <QStatusBar>
#include <QTimer>
#include <QToolBar>
#include <QTreeView>
#include <QVBoxLayout>
#include <QWidget>
#include "mainwindow/CategoryPanel.hpp"
#include "models/DownloadTableModel.hpp"
#include "rpc/RpcClient.hpp"
#include "widgets/ProgressDelegate.hpp"
@@ -43,19 +48,19 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
: QMainWindow(parent),
client_(client),
model_(new DownloadTableModel(this)),
proxy_(new QSortFilterProxyModel(this)),
proxy_(new DownloadFilterProxy(this)),
view_(new QTreeView(this)),
panel_(new CategoryPanel(this)),
connDot_(new QLabel(QStringLiteral(""), this)),
connText_(new QLabel(this)),
countsLabel_(new QLabel(this)),
offlineBanner_(new QWidget(this)) {
offlineBanner_(new QWidget(this)),
countsTimer_(new QTimer(this)) {
setWindowTitle(tr("Velox Download Manager"));
resize(960, 560);
resize(1040, 600);
proxy_->setSourceModel(model_);
proxy_->setDynamicSortFilter(true);
proxy_->setSortCaseSensitivity(Qt::CaseInsensitive);
proxy_->setFilterKeyColumn(-1);
view_->setModel(proxy_);
view_->setRootIsDecorated(false);
@@ -78,25 +83,25 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
offlineBanner_->setStyleSheet(QStringLiteral("background: #5a3a00; color: #ffd9a0;"));
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 *layout = new QVBoxLayout(central);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
layout->addWidget(offlineBanner_);
layout->addWidget(view_);
layout->addWidget(splitter, 1);
setCentralWidget(central);
// --- toolbar (dialog-free actions only for the M1 slice) -------------------------
auto *tb = addToolBar(tr("Main"));
tb->setMovable(false);
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));
buildActions();
buildMenus();
buildToolBar();
// --- status bar -----------------------------------------------------------------
connDot_->setStyleSheet(dotStyle(QStringLiteral("#c0392b")));
@@ -104,6 +109,11 @@ MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
statusBar()->addPermanentWidget(connText_);
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 --------------------------------------------------------------------
connect(client_, &rpc::RpcClient::stateChanged, this, &MainWindow::onConnectionState);
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::speedGlobal, this, &MainWindow::onSpeedGlobal);
connect(model_, &QAbstractItemModel::modelReset, this, &MainWindow::updateCounts);
connect(model_, &QAbstractItemModel::rowsInserted, this, &MainWindow::updateCounts);
connect(model_, &QAbstractItemModel::rowsRemoved, this, &MainWindow::updateCounts);
connect(model_, &QAbstractItemModel::dataChanged, this, &MainWindow::updateCounts);
connect(model_, &QAbstractItemModel::modelReset, this, &MainWindow::scheduleCounts);
connect(model_, &QAbstractItemModel::rowsInserted, this, &MainWindow::scheduleCounts);
connect(model_, &QAbstractItemModel::rowsRemoved, this, &MainWindow::scheduleCounts);
connect(model_, &QAbstractItemModel::dataChanged, this, &MainWindow::scheduleCounts);
connect(panel_, &CategoryPanel::selectionChanged, this, &MainWindow::onFilterChanged);
restoreLayout();
onConnectionState(client_->state());
updateCounts();
refreshCounts();
}
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) {
saveLayout();
QMainWindow::closeEvent(event);
@@ -151,24 +237,85 @@ 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_}) {
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 &params) {
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();
int active = 0;
int unfinished = 0;
int finished = 0;
QHash<QString, int> byCategory;
QHash<QString, int> byQueue;
for (int i = 0; i < total; ++i) {
if (model_->index(i, 0).data(DownloadTableModel::StateRole).toString() ==
QLatin1String("downloading")) {
const QModelIndex idx = model_->index(i, 0);
const QString state = idx.data(DownloadTableModel::StateRole).toString();
if (state == QLatin1String("downloading")) {
++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 {
@@ -177,8 +324,8 @@ QStringList MainWindow::selectedTaskIds() const {
view_->selectionModel() ? view_->selectionModel()->selectedRows() : QModelIndexList{};
ids.reserve(rows.size());
for (const QModelIndex &proxyIdx : rows) {
const QModelIndex src = proxy_->mapToSource(proxyIdx);
const QString id = model_->data(src, DownloadTableModel::TaskIdRole).toString();
const QString id =
proxy_->mapToSource(proxyIdx).data(DownloadTableModel::TaskIdRole).toString();
if (!id.isEmpty()) {
ids << id;
}
@@ -186,8 +333,17 @@ QStringList MainWindow::selectedTaskIds() const {
return ids;
}
void MainWindow::actOnSelection(const char *methodName) {
const QStringList ids = selectedTaskIds();
QStringList MainWindow::allTaskIds() const {
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()) {
return;
}
@@ -196,13 +352,33 @@ void MainWindow::actOnSelection(const char *methodName) {
}
void MainWindow::pauseSelection() {
actOnSelection(rpc::method::kDownloadPause);
actOnTasks(rpc::method::kDownloadPause, selectedTaskIds());
}
void MainWindow::resumeSelection() {
actOnSelection(rpc::method::kDownloadResume);
actOnTasks(rpc::method::kDownloadResume, selectedTaskIds());
}
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() {
@@ -215,7 +391,17 @@ void MainWindow::restoreLayout() {
settings.value(QStringLiteral("mainwindow/headerState")).toByteArray();
if (!header.isEmpty()) {
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() {
+31 -7
View File
@@ -1,22 +1,26 @@
// The main window. Lane GUI.
//
// M1 slice: category tree, dialogs and the tray come later. This is the table driven by
// mockd, a status-bar connection dot, an offline banner instead of a modal, and the three
// selection actions that need no dialog (pause / resume / cancel).
// M1 slice: dialogs and the tray still come later. This is the menus + toolbar + splitter
// (category tree | table), the table driven by mockd, a status-bar connection dot, an
// offline banner instead of a modal, and the actions that need no dialog.
#pragma once
#include <QHash>
#include <QMainWindow>
#include "models/DownloadFilterProxy.hpp"
#include "rpc/Protocol.hpp"
class QAction;
class QLabel;
class QSortFilterProxyModel;
class QTimer;
class QTreeView;
namespace velox::gui {
class DownloadTableModel;
class CategoryPanel;
namespace rpc {
class RpcClient;
} // namespace rpc
@@ -34,27 +38,47 @@ class MainWindow : public QMainWindow {
private slots:
void onConnectionState(rpc::ConnectionState state);
void onSpeedGlobal(const QJsonObject &params);
void updateCounts();
void onFilterChanged(const velox::gui::TaskSelection &selection);
void scheduleCounts(); // throttled: fires refreshCounts() at most every 400 ms
void refreshCounts();
void pauseSelection();
void resumeSelection();
void cancelSelection();
void removeSelection();
void resumeAll();
void pauseAll();
private:
void buildActions();
void buildMenus();
void buildToolBar();
void fetchTree();
QStringList selectedTaskIds() const;
void actOnSelection(const char *methodName);
QStringList allTaskIds() const;
void actOnTasks(const char *methodName, const QStringList &ids);
void restoreLayout();
void saveLayout();
rpc::RpcClient *client_;
DownloadTableModel *model_;
QSortFilterProxyModel *proxy_;
DownloadFilterProxy *proxy_;
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 *connText_;
QLabel *countsLabel_;
QWidget *offlineBanner_;
QTimer *countsTimer_;
qint64 globalDownBps_ = 0;
};
+71
View File
@@ -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
+44
View File
@@ -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
+4
View File
@@ -89,6 +89,10 @@ QVariant DownloadTableModel::data(const QModelIndex &index, int role) const {
return r.taskId;
case StateRole:
return r.state;
case CategoryIdRole:
return r.categoryId;
case QueueIdRole:
return r.queueId;
case ProgressRole:
return progressOf(r);
case Qt::TextAlignmentRole:
+3 -1
View File
@@ -34,7 +34,9 @@ class DownloadTableModel : public QAbstractTableModel {
enum Roles {
ProgressRole = Qt::UserRole + 1, ///< double in [0,1], -1 when the size is unknown
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);
+10
View File
@@ -22,6 +22,16 @@ target_link_libraries(tst_downloadtablemodel PRIVATE Qt6::Core Qt6::Test)
add_test(NAME gui_downloadtablemodel COMMAND tst_downloadtablemodel)
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).
# 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.
+93
View File
@@ -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"