gui: category tree, menus, toolbar, splitter — build-order step 3
- CategoryPanel: the left tree — All Downloads / Unfinished / Finished, then Categories and Queues populated from category.list / queue.list, with per-node task counts. Selecting a node emits a TaskSelection. - DownloadFilterProxy: QSortFilterProxyModel keyed off that selection. Client-side for M1 (the whole list fits); asTaskFilter() exposes the equivalent TaskFilter for a server-side download.list once paging lands. - MainWindow: menu bar (Tasks / Downloads / View / Help) sharing QAction objects with the toolbar; QSplitter [panel | table]; Delete with a confirm; Resume/Pause All; View menu toggles the panel. Actions disabled while offline. - Counts are computed off a throttled 400 ms timer, not the 4 Hz progress path. Fixed a debounce-vs-throttle bug found in the first screenshot: restarting the timer on every progress tick meant it never fired and the status bar sat at "0 of 0 downloads". - First-run column widths that fit the content. - tst_downloadfilterproxy: nodes filter to their own rows, the Finished/Unfinished split is correct, and the filter is dynamic (a row that finishes leaves the Unfinished node with no re-list). Verified against mockd --tasks 400 (screenshot: tree counts 75/84/89/83/69 sum to 400, status bar "400 of 400, 21 active"). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_016Ne28kx4VreeBWZv82Nksd
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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 <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 ¶ms) {
|
||||
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() {
|
||||
|
||||
@@ -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 ¶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 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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
case StateRole:
|
||||
return r.state;
|
||||
case CategoryIdRole:
|
||||
return r.categoryId;
|
||||
case QueueIdRole:
|
||||
return r.queueId;
|
||||
case ProgressRole:
|
||||
return progressOf(r);
|
||||
case Qt::TextAlignmentRole:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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