From 51fa1201bd6948a322a81f272801122728bc848a Mon Sep 17 00:00:00 2001 From: sami Date: Thu, 10 Sep 2026 15:41:29 +0400 Subject: [PATCH] =?UTF-8?q?gui:=20category=20tree,=20menus,=20toolbar,=20s?= =?UTF-8?q?plitter=20=E2=80=94=20build-order=20step=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_016Ne28kx4VreeBWZv82Nksd --- gui/CMakeLists.txt | 2 + gui/src/mainwindow/CategoryPanel.cpp | 128 ++++++++++++ gui/src/mainwindow/CategoryPanel.hpp | 47 +++++ gui/src/mainwindow/MainWindow.cpp | 258 +++++++++++++++++++++---- gui/src/mainwindow/MainWindow.hpp | 38 +++- gui/src/models/DownloadFilterProxy.cpp | 71 +++++++ gui/src/models/DownloadFilterProxy.hpp | 44 +++++ gui/src/models/DownloadTableModel.cpp | 4 + gui/src/models/DownloadTableModel.hpp | 4 +- gui/tests/CMakeLists.txt | 10 + gui/tests/tst_downloadfilterproxy.cpp | 93 +++++++++ 11 files changed, 655 insertions(+), 44 deletions(-) create mode 100644 gui/src/mainwindow/CategoryPanel.cpp create mode 100644 gui/src/mainwindow/CategoryPanel.hpp create mode 100644 gui/src/models/DownloadFilterProxy.cpp create mode 100644 gui/src/models/DownloadFilterProxy.hpp create mode 100644 gui/tests/tst_downloadfilterproxy.cpp diff --git a/gui/CMakeLists.txt b/gui/CMakeLists.txt index 139c938..ef7c314 100644 --- a/gui/CMakeLists.txt +++ b/gui/CMakeLists.txt @@ -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 ) diff --git a/gui/src/mainwindow/CategoryPanel.cpp b/gui/src/mainwindow/CategoryPanel.cpp new file mode 100644 index 0000000..cbc6455 --- /dev/null +++ b/gui/src/mainwindow/CategoryPanel.cpp @@ -0,0 +1,128 @@ +#include "mainwindow/CategoryPanel.hpp" + +#include +#include +#include +#include + +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(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(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 &byCategory, + const QHash &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 diff --git a/gui/src/mainwindow/CategoryPanel.hpp b/gui/src/mainwindow/CategoryPanel.hpp new file mode 100644 index 0000000..78c7dc0 --- /dev/null +++ b/gui/src/mainwindow/CategoryPanel.hpp @@ -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 +#include +#include + +#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 &byCategory, + const QHash &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 diff --git a/gui/src/mainwindow/MainWindow.cpp b/gui/src/mainwindow/MainWindow.cpp index 60de041..c36ebf4 100644 --- a/gui/src/mainwindow/MainWindow.cpp +++ b/gui/src/mainwindow/MainWindow.cpp @@ -10,14 +10,19 @@ #include #include #include +#include +#include +#include #include -#include +#include #include +#include #include #include #include #include +#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(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 byCategory; + QHash 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() { diff --git a/gui/src/mainwindow/MainWindow.hpp b/gui/src/mainwindow/MainWindow.hpp index 3063b23..edaf3b3 100644 --- a/gui/src/mainwindow/MainWindow.hpp +++ b/gui/src/mainwindow/MainWindow.hpp @@ -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 #include +#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; }; diff --git a/gui/src/models/DownloadFilterProxy.cpp b/gui/src/models/DownloadFilterProxy.cpp new file mode 100644 index 0000000..0c765d3 --- /dev/null +++ b/gui/src/models/DownloadFilterProxy.cpp @@ -0,0 +1,71 @@ +#include "models/DownloadFilterProxy.hpp" + +#include +#include + +#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 diff --git a/gui/src/models/DownloadFilterProxy.hpp b/gui/src/models/DownloadFilterProxy.hpp new file mode 100644 index 0000000..d645a6e --- /dev/null +++ b/gui/src/models/DownloadFilterProxy.hpp @@ -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 +#include + +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 diff --git a/gui/src/models/DownloadTableModel.cpp b/gui/src/models/DownloadTableModel.cpp index 733f8da..34ad12d 100644 --- a/gui/src/models/DownloadTableModel.cpp +++ b/gui/src/models/DownloadTableModel.cpp @@ -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: diff --git a/gui/src/models/DownloadTableModel.hpp b/gui/src/models/DownloadTableModel.hpp index 70d97bf..c8cf30e 100644 --- a/gui/src/models/DownloadTableModel.hpp +++ b/gui/src/models/DownloadTableModel.hpp @@ -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); diff --git a/gui/tests/CMakeLists.txt b/gui/tests/CMakeLists.txt index ccc64f8..3428ead 100644 --- a/gui/tests/CMakeLists.txt +++ b/gui/tests/CMakeLists.txt @@ -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. diff --git a/gui/tests/tst_downloadfilterproxy.cpp b/gui/tests/tst_downloadfilterproxy.cpp new file mode 100644 index 0000000..95a9fcd --- /dev/null +++ b/gui/tests/tst_downloadfilterproxy.cpp @@ -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 +#include +#include + +#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"