Continues the build order past Options/Scheduler/Speed Limiter/Batch/ Grabber/tray. - DropTargetWidget: frameless always-on-top drop target (docs/03-gui-spec.md §5), position persisted, accepts a dropped http(s) URL or link text and opens FileInfoDialog directly (skipping Add URL, since the URL is already known). Shown/hidden from general.showDropTarget, live via event.settings.changed, same pattern MainWindow already used for general.minimizeToTray. - Clipboard, explicit path #2 (docs/06-risks-and-spikes.md R2): GlobalShortcut wraps org.freedesktop.portal.GlobalShortcuts (CreateSession -> BindShortcuts -> Activated), triggering the same Add URL flow. Guarded end-to-end on `if(TARGET Qt6::DBus)` / VELOX_GUI_HAVE_DBUS so a build without the component degrades to "feature skipped," not broken (gui/docs/pkg-qa-requests-m1.md R4). Best-effort by design per the risk doc: fails silent, never advertised. Verified live against the real portal (a real Wayland session, not just offscreen): `CreateSession` refuses every caller with "An app id is required" — reproduced identically via a bare `busctl` call with no Qt involved at all, so this is the portal requiring a sandboxed caller identity, not something fixable from an unconfined process. Recorded as a partial Spike S2 answer in docs/06-risks-and-spikes.md: this explicit path likely doesn't work for Velox as a traditionally-packaged app on stock GNOME, only if/when it ships confined. Also fixed a real leak this verification caught: QDBusInterface's introspection cache reads as a LeakSanitizer leak the first time anything touches D-Bus (tst_rtl went red under ASan) — switched to QDBusMessage::createMethodCall, which needs no introspection. - Theming (docs/03-gui-spec.md §7): gui/resources/qss/{idm-like,dark}.qss, each with a documented palette block up top (QSS itself has no variable syntax), applied by ThemeManager and kept live via QStyleHints::colorSchemeChanged. util/Theme.hpp gives the handful of inline C++ styles (status dot, offline banner, the eleven identical error-label styles across dialogs) named constants instead of a twelfth copy of the same hex. - UiThreadWatchdog: the M1 DoD's 200 ms debug-build watchdog. A background std::thread pings the UI thread every 50 ms via a queued invokeMethod and warns once (not per-poll) if a ping goes unanswered past 200 ms; no QThread, no Qt event loop of its own, so the watchdog itself can never be what blocks the thread it watches. No-op in a release build. Proven both ways in tst_uithreadwatchdog: fires on a genuinely blocked UI thread (synchronous sleep, no processEvents) and stays silent on a responsive one. Full non-conformance suite (55 tests across every lane, `ctest -LE conformance`) passes clean at this point, including the whole gui label under ASan+UBSan. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
622 lines
22 KiB
C++
622 lines
22 KiB
C++
#include "mainwindow/MainWindow.hpp"
|
|
|
|
#include <QAction>
|
|
#include <QCloseEvent>
|
|
#include <QCoreApplication>
|
|
#include <QHBoxLayout>
|
|
#include <QHeaderView>
|
|
#include <QItemSelectionModel>
|
|
#include <QJsonArray>
|
|
#include <QJsonObject>
|
|
#include <QKeySequence>
|
|
#include <QLabel>
|
|
#include <QLocale>
|
|
#include <QMenu>
|
|
#include <QMenuBar>
|
|
#include <QMessageBox>
|
|
#include <QSettings>
|
|
#include <QSplitter>
|
|
#include <QStatusBar>
|
|
#include <QSystemTrayIcon>
|
|
#include <QTimer>
|
|
#include <QToolBar>
|
|
#include <QTreeView>
|
|
#include <QVBoxLayout>
|
|
#include <QWidget>
|
|
|
|
#include "dialogs/AddUrlDialog.hpp"
|
|
#include "dialogs/BatchDialog.hpp"
|
|
#include "dialogs/FileInfoDialog.hpp"
|
|
#include "dialogs/GrabberWizard.hpp"
|
|
#include "dialogs/OptionsDialog.hpp"
|
|
#include "dialogs/ProgressDialog.hpp"
|
|
#include "dialogs/SchedulerDialog.hpp"
|
|
#include "dialogs/SpeedLimiterDialog.hpp"
|
|
#include "mainwindow/CategoryPanel.hpp"
|
|
#include "models/DownloadTableModel.hpp"
|
|
#include "rpc/RpcClient.hpp"
|
|
#include "tray/TrayIcon.hpp"
|
|
#include "util/Format.hpp"
|
|
#include "util/Theme.hpp"
|
|
#include "widgets/DropTargetWidget.hpp"
|
|
#include "widgets/ProgressDelegate.hpp"
|
|
|
|
#ifdef VELOX_GUI_HAVE_DBUS
|
|
#include "clipboard/GlobalShortcut.hpp"
|
|
#endif
|
|
|
|
namespace velox::gui {
|
|
namespace {
|
|
|
|
QString dotStyle(const QString &colour) {
|
|
return QStringLiteral("color: %1; font-size: 14px;").arg(colour);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
MainWindow::MainWindow(rpc::RpcClient *client, QWidget *parent)
|
|
: QMainWindow(parent),
|
|
client_(client),
|
|
model_(new DownloadTableModel(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)),
|
|
countsTimer_(new QTimer(this)) {
|
|
setWindowTitle(tr("Velox Download Manager"));
|
|
resize(1040, 600);
|
|
|
|
proxy_->setSourceModel(model_);
|
|
proxy_->setSortCaseSensitivity(Qt::CaseInsensitive);
|
|
|
|
view_->setModel(proxy_);
|
|
view_->setRootIsDecorated(false);
|
|
view_->setAlternatingRowColors(true);
|
|
view_->setUniformRowHeights(true); // keeps 10k rows scrolling at 60 fps
|
|
view_->setSelectionBehavior(QAbstractItemView::SelectRows);
|
|
view_->setSelectionMode(QAbstractItemView::ExtendedSelection);
|
|
view_->setSortingEnabled(true);
|
|
view_->setItemDelegateForColumn(DownloadTableModel::ColStatus, new ProgressDelegate(this));
|
|
view_->setContextMenuPolicy(Qt::CustomContextMenu);
|
|
connect(view_, &QWidget::customContextMenuRequested, this, &MainWindow::showTableContextMenu);
|
|
connect(view_, &QAbstractItemView::doubleClicked, this,
|
|
&MainWindow::openPropertiesForSelection);
|
|
view_->header()->setSectionsMovable(true);
|
|
view_->header()->setStretchLastSection(true);
|
|
|
|
// --- offline banner (shown instead of a modal error) -----------------------------
|
|
auto *bannerLayout = new QHBoxLayout(offlineBanner_);
|
|
bannerLayout->setContentsMargins(10, 6, 10, 6);
|
|
auto *bannerLabel = new QLabel(offlineBanner_);
|
|
bannerLabel->setObjectName(QStringLiteral("offlineBannerLabel"));
|
|
bannerLayout->addWidget(bannerLabel);
|
|
bannerLayout->addStretch();
|
|
offlineBanner_->setStyleSheet(
|
|
QStringLiteral("background: %1; color: %2;")
|
|
.arg(QLatin1String(theme::kOfflineBannerBg), QLatin1String(theme::kOfflineBannerText)));
|
|
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(splitter, 1);
|
|
setCentralWidget(central);
|
|
|
|
buildActions();
|
|
buildMenus();
|
|
buildToolBar();
|
|
buildTray();
|
|
buildDropTarget();
|
|
|
|
// --- status bar -----------------------------------------------------------------
|
|
connDot_->setStyleSheet(dotStyle(QLatin1String(theme::kDanger)));
|
|
statusBar()->addPermanentWidget(countsLabel_, 1);
|
|
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);
|
|
connect(client_, &rpc::RpcClient::taskProgress, model_, &DownloadTableModel::applyProgress);
|
|
connect(client_, &rpc::RpcClient::taskAdded, model_, &DownloadTableModel::applyTaskAdded);
|
|
connect(client_, &rpc::RpcClient::taskStateChanged, model_,
|
|
&DownloadTableModel::applyTaskState);
|
|
connect(client_, &rpc::RpcClient::taskRemoved, model_, &DownloadTableModel::applyTaskRemoved);
|
|
connect(client_, &rpc::RpcClient::speedGlobal, this, &MainWindow::onSpeedGlobal);
|
|
connect(client_, &rpc::RpcClient::settingsChanged, this, &MainWindow::onSettingsChanged);
|
|
|
|
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());
|
|
refreshCounts();
|
|
}
|
|
|
|
MainWindow::~MainWindow() = default;
|
|
|
|
void MainWindow::buildActions() {
|
|
actAddUrl_ = new QAction(tr("Add &URL…"), this);
|
|
actAddUrl_->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_N));
|
|
connect(actAddUrl_, &QAction::triggered, this, &MainWindow::openAddUrlDialog);
|
|
|
|
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);
|
|
|
|
actProperties_ = new QAction(tr("&Properties…"), this);
|
|
connect(actProperties_, &QAction::triggered, this, &MainWindow::openPropertiesForSelection);
|
|
|
|
actOptions_ = new QAction(tr("&Options…"), this);
|
|
connect(actOptions_, &QAction::triggered, this, &MainWindow::openOptionsDialog);
|
|
|
|
actScheduler_ = new QAction(tr("&Scheduler…"), this);
|
|
connect(actScheduler_, &QAction::triggered, this, &MainWindow::openSchedulerDialog);
|
|
|
|
actSpeedLimiter_ = new QAction(tr("Speed &Limiter…"), this);
|
|
connect(actSpeedLimiter_, &QAction::triggered, this, &MainWindow::openSpeedLimiterDialog);
|
|
|
|
actBatch_ = new QAction(tr("&Batch Download…"), this);
|
|
connect(actBatch_, &QAction::triggered, this, &MainWindow::openBatchDialog);
|
|
|
|
actGrabber_ = new QAction(tr("Site &Grabber…"), this);
|
|
connect(actGrabber_, &QAction::triggered, this, &MainWindow::openGrabberWizard);
|
|
|
|
actShow_ = new QAction(tr("&Show Velox"), this);
|
|
connect(actShow_, &QAction::triggered, this, &MainWindow::showAndRaise);
|
|
}
|
|
|
|
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->addAction(actProperties_);
|
|
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();
|
|
downloads->addAction(actScheduler_);
|
|
downloads->addAction(actSpeedLimiter_);
|
|
|
|
QMenu *tools = menuBar()->addMenu(tr("&Tools"));
|
|
tools->addAction(actBatch_);
|
|
tools->addAction(actGrabber_);
|
|
tools->addSeparator();
|
|
tools->addAction(actOptions_);
|
|
|
|
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_);
|
|
tb->addSeparator();
|
|
tb->addAction(actScheduler_);
|
|
tb->addAction(actOptions_);
|
|
tb->addAction(actGrabber_);
|
|
}
|
|
|
|
void MainWindow::buildTray() {
|
|
if (!QSystemTrayIcon::isSystemTrayAvailable()) {
|
|
return; // headless/mockd-only test environments, some minimal WMs
|
|
}
|
|
trayIcon_ = new TrayIcon(client_, actShow_, actAddUrl_, actResumeAll_, actPauseAll_, this);
|
|
connect(trayIcon_, &TrayIcon::quitRequested, qApp, &QCoreApplication::quit);
|
|
trayIcon_->show();
|
|
}
|
|
|
|
void MainWindow::buildDropTarget() {
|
|
// Qt::Tool + a parent keeps it grouped with the main window (no separate taskbar
|
|
// entry, destroyed when MainWindow is) while still floating independently per spec.
|
|
dropTarget_ = new DropTargetWidget(this);
|
|
connect(dropTarget_, &DropTargetWidget::urlDropped, this, &MainWindow::onUrlDropped);
|
|
connect(dropTarget_, &DropTargetWidget::addUrlRequested, this, &MainWindow::openAddUrlDialog);
|
|
|
|
#ifdef VELOX_GUI_HAVE_DBUS
|
|
// docs/06-risks-and-spikes.md R2, explicit path #2. Best-effort: requestBinding() is
|
|
// silent and permanent-for-this-session on any desktop that lacks the portal or
|
|
// declines the prompt — see GlobalShortcut's own header for why that's by design.
|
|
globalShortcut_ = new GlobalShortcut(this);
|
|
connect(globalShortcut_, &GlobalShortcut::activated, this, &MainWindow::openAddUrlDialog);
|
|
globalShortcut_->requestBinding();
|
|
#endif
|
|
}
|
|
|
|
void MainWindow::closeEvent(QCloseEvent *event) {
|
|
if (minimizeToTrayEnabled_ && trayIcon_ && trayIcon_->isVisible()) {
|
|
hide();
|
|
event->ignore();
|
|
return;
|
|
}
|
|
saveLayout();
|
|
QMainWindow::closeEvent(event);
|
|
event->accept();
|
|
}
|
|
|
|
void MainWindow::onConnectionState(rpc::ConnectionState state) {
|
|
connText_->setText(tr(rpc::toString(state)));
|
|
|
|
QString colour = QLatin1String(theme::kDanger);
|
|
if (state == rpc::ConnectionState::Connected) {
|
|
colour = QLatin1String(theme::kSuccess);
|
|
} else if (state != rpc::ConnectionState::Disconnected) {
|
|
colour = QLatin1String(theme::kWarning);
|
|
}
|
|
connDot_->setStyleSheet(dotStyle(colour));
|
|
|
|
const bool online = state == rpc::ConnectionState::Connected;
|
|
offlineBanner_->setVisible(!online);
|
|
if (auto *label = offlineBanner_->findChild<QLabel *>(QStringLiteral("offlineBannerLabel"))) {
|
|
label->setText(state == rpc::ConnectionState::Reconnecting
|
|
? tr("Reconnecting to the Velox service…")
|
|
: 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_, actAddUrl_,
|
|
actProperties_, actScheduler_, actSpeedLimiter_, actBatch_, actGrabber_, actOptions_}) {
|
|
a->setEnabled(online);
|
|
}
|
|
|
|
if (online) {
|
|
fetchTree();
|
|
fetchGeneralUiSettings();
|
|
}
|
|
}
|
|
|
|
void MainWindow::fetchTree() {
|
|
client_->call(QStringLiteral("category.list"), {}, [this](const rpc::RpcReply &reply) {
|
|
if (reply.ok()) {
|
|
categoriesCache_ = reply.result.toObject().value("items").toArray();
|
|
panel_->setCategories(categoriesCache_);
|
|
}
|
|
});
|
|
client_->call(QStringLiteral("queue.list"), {}, [this](const rpc::RpcReply &reply) {
|
|
if (reply.ok()) {
|
|
queuesCache_ = reply.result.toObject().value("items").toArray();
|
|
panel_->setQueues(queuesCache_);
|
|
}
|
|
});
|
|
}
|
|
|
|
void MainWindow::fetchGeneralUiSettings() {
|
|
client_->call(
|
|
QStringLiteral("settings.get"),
|
|
QJsonObject{{"keys", QJsonArray{QStringLiteral("general.minimizeToTray"),
|
|
QStringLiteral("general.showDropTarget")}}},
|
|
[this](const rpc::RpcReply &reply) {
|
|
if (!reply.ok()) {
|
|
return;
|
|
}
|
|
const QJsonObject values = reply.result.toObject().value("values").toObject();
|
|
minimizeToTrayEnabled_ = values.value("general.minimizeToTray").toBool();
|
|
if (dropTarget_) {
|
|
dropTarget_->setVisible(values.value("general.showDropTarget").toBool(true));
|
|
}
|
|
});
|
|
}
|
|
|
|
void MainWindow::onSettingsChanged(const QJsonObject ¶ms) {
|
|
for (const QJsonValue &key : params.value("keys").toArray()) {
|
|
const QString k = key.toString();
|
|
if (k == QLatin1String("general.minimizeToTray") ||
|
|
k == QLatin1String("general.showDropTarget")) {
|
|
fetchGeneralUiSettings();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void MainWindow::onUrlDropped(const QString &url) {
|
|
auto *info = new FileInfoDialog(client_, url, categoriesCache_, queuesCache_, this);
|
|
info->setAttribute(Qt::WA_DeleteOnClose);
|
|
info->show();
|
|
}
|
|
|
|
void MainWindow::openAddUrlDialog() {
|
|
AddUrlDialog dlg(this);
|
|
if (dlg.exec() != QDialog::Accepted) {
|
|
return;
|
|
}
|
|
const QString url = dlg.url();
|
|
if (url.isEmpty()) {
|
|
return;
|
|
}
|
|
// Non-modal: several File Info dialogs can be open at once (spec §2), and it must
|
|
// never block the main window while download.probe is in flight.
|
|
auto *info = new FileInfoDialog(client_, url, categoriesCache_, queuesCache_, this);
|
|
info->setAttribute(Qt::WA_DeleteOnClose);
|
|
info->show();
|
|
}
|
|
|
|
void MainWindow::openPropertiesForSelection() {
|
|
const QStringList ids = selectedTaskIds();
|
|
if (ids.isEmpty()) {
|
|
return;
|
|
}
|
|
// One Progress dialog per selected task, same as double-clicking each row.
|
|
for (const QString &id : ids) {
|
|
auto *dlg = new ProgressDialog(client_, id, this);
|
|
dlg->show();
|
|
}
|
|
}
|
|
|
|
void MainWindow::openOptionsDialog() {
|
|
OptionsDialog dlg(client_, this);
|
|
dlg.exec();
|
|
}
|
|
|
|
void MainWindow::openSchedulerDialog() {
|
|
SchedulerDialog dlg(client_, this);
|
|
dlg.exec();
|
|
}
|
|
|
|
void MainWindow::openSpeedLimiterDialog() {
|
|
SpeedLimiterDialog dlg(client_, this);
|
|
dlg.exec();
|
|
}
|
|
|
|
void MainWindow::openBatchDialog() {
|
|
BatchDialog dlg(client_, categoriesCache_, queuesCache_, this);
|
|
dlg.exec();
|
|
}
|
|
|
|
void MainWindow::openGrabberWizard() {
|
|
auto *wizard = new GrabberWizard(client_, categoriesCache_, this);
|
|
wizard->setAttribute(Qt::WA_DeleteOnClose);
|
|
wizard->show();
|
|
}
|
|
|
|
void MainWindow::showAndRaise() {
|
|
show();
|
|
raise();
|
|
activateWindow();
|
|
}
|
|
|
|
// docs/03-gui-spec.md §1 lists a fuller row menu (Open · Open With · Open Folder ·
|
|
// Move/Rename · Redownload · Refresh Download Address · Add to Queue ▸ · ...). Those all
|
|
// need state this build order hasn't reached yet (Options' file-type associations aren't
|
|
// per-row actions, and Batch's "Add to Queue" is a different flow than re-filing an
|
|
// existing task) — offering them now would be a fake affordance.
|
|
void MainWindow::showTableContextMenu(const QPoint &pos) {
|
|
const QModelIndex idx = view_->indexAt(pos);
|
|
if (idx.isValid() && view_->selectionModel() && !view_->selectionModel()->isSelected(idx)) {
|
|
view_->selectionModel()->setCurrentIndex(
|
|
idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
|
|
}
|
|
if (selectedTaskIds().isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
QMenu menu(this);
|
|
menu.addAction(actResume_);
|
|
menu.addAction(actPause_);
|
|
menu.addAction(actStop_);
|
|
menu.addSeparator();
|
|
menu.addAction(actRemove_);
|
|
menu.addSeparator();
|
|
menu.addAction(actProperties_);
|
|
menu.exec(view_->viewport()->mapToGlobal(pos));
|
|
}
|
|
|
|
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());
|
|
scheduleCounts();
|
|
}
|
|
|
|
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) {
|
|
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];
|
|
}
|
|
}
|
|
|
|
panel_->setCounts(total, unfinished, finished, byCategory, byQueue);
|
|
if (trayIcon_) {
|
|
trayIcon_->setActiveCount(active);
|
|
}
|
|
|
|
const int shown = proxy_->rowCount();
|
|
countsLabel_->setText(tr("%1 of %2 downloads, %3 active ↓ %4")
|
|
.arg(shown)
|
|
.arg(total)
|
|
.arg(active)
|
|
.arg(fmt::rate(globalDownBps_)));
|
|
}
|
|
|
|
QStringList MainWindow::selectedTaskIds() const {
|
|
QStringList ids;
|
|
const auto rows =
|
|
view_->selectionModel() ? view_->selectionModel()->selectedRows() : QModelIndexList{};
|
|
ids.reserve(rows.size());
|
|
for (const QModelIndex &proxyIdx : rows) {
|
|
const QString id =
|
|
proxy_->mapToSource(proxyIdx).data(DownloadTableModel::TaskIdRole).toString();
|
|
if (!id.isEmpty()) {
|
|
ids << id;
|
|
}
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
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;
|
|
}
|
|
client_->call(QString::fromLatin1(methodName),
|
|
QJsonObject{{"taskIds", QJsonArray::fromStringList(ids)}});
|
|
}
|
|
|
|
void MainWindow::pauseSelection() {
|
|
actOnTasks(rpc::method::kDownloadPause, selectedTaskIds());
|
|
}
|
|
void MainWindow::resumeSelection() {
|
|
actOnTasks(rpc::method::kDownloadResume, selectedTaskIds());
|
|
}
|
|
void MainWindow::cancelSelection() {
|
|
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() {
|
|
QSettings settings;
|
|
const QByteArray geom = settings.value(QStringLiteral("mainwindow/geometry")).toByteArray();
|
|
if (!geom.isEmpty()) {
|
|
restoreGeometry(geom);
|
|
}
|
|
const QByteArray header =
|
|
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() {
|
|
QSettings settings;
|
|
settings.setValue(QStringLiteral("mainwindow/geometry"), saveGeometry());
|
|
settings.setValue(QStringLiteral("mainwindow/headerState"), view_->header()->saveState());
|
|
}
|
|
|
|
} // namespace velox::gui
|