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
+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() {