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
+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;
};