#include "mainwindow/MainWindow.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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(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(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 byCategory; QHash 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