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:
2026-09-10 15:41:29 +04:00
co-authored by Claude Sonnet 5
parent 170bcfdb3e
commit 51fa1201bd
11 changed files with 655 additions and 44 deletions
+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);