gui: RPC client, download table model, and a live main window
First vertical slice of velox-gui, built entirely against tools/mockd (no daemon dependency): - rpc/: RpcConnection runs a QLocalSocket on a worker thread with newline-delimited JSON-RPC framing, drives the session.hello / session.subscribe handshake, and reconnects with exponential backoff (250 ms -> 8 s). RpcClient is the main-thread face: marshals calls onto the worker, delivers replies as main-thread callbacks, re-emits server notifications as typed Qt signals, and issues the one-shot download.list on reaching Connected. - models/DownloadTableModel: QAbstractTableModel over TaskSummary. A progress batch is a row patch with a narrow dataChanged over the value columns only; beginResetModel() is reserved for the initial load and a reconnect resync. - widgets/ProgressDelegate: in-cell progress bar for the Status column. - mainwindow/MainWindow: the table, a status-bar connection dot, an offline banner instead of a modal, dialog-free pause/resume/stop actions, and QSettings column/geometry persistence. - gui/CMakeLists.txt links velox::proto (never velox::core, ADR 0009) and self-guards on the veloxproto target so main keeps configuring if it is ever absent again. - i18n from the first commit: every string via tr(), plus an Arabic .ts stub for the RTL check. - tests/: headless QTest for the model — proves the progress patch is a narrow dataChanged and never resets the model. Verified end-to-end against `mockd --tasks 300`: handshake, initial list, live progress batches applied to the model, and a clean Reconnecting -> Connected recovery when mockd is bounced mid-run. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_016Ne28kx4VreeBWZv82Nksd
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
#include "mainwindow/MainWindow.hpp"
|
||||
|
||||
#include <QAction>
|
||||
#include <QCloseEvent>
|
||||
#include <QHBoxLayout>
|
||||
#include <QHeaderView>
|
||||
#include <QItemSelectionModel>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QKeySequence>
|
||||
#include <QLabel>
|
||||
#include <QLocale>
|
||||
#include <QSettings>
|
||||
#include <QSortFilterProxyModel>
|
||||
#include <QStatusBar>
|
||||
#include <QToolBar>
|
||||
#include <QTreeView>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
|
||||
#include "models/DownloadTableModel.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
#include "widgets/ProgressDelegate.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
QString humanRate(qint64 bps) {
|
||||
if (bps <= 0) {
|
||||
return QStringLiteral("0 B/s");
|
||||
}
|
||||
return MainWindow::tr("%1/s").arg(
|
||||
QLocale().formattedDataSize(bps, 1, QLocale::DataSizeIecFormat));
|
||||
}
|
||||
|
||||
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 QSortFilterProxyModel(this)),
|
||||
view_(new QTreeView(this)),
|
||||
connDot_(new QLabel(QStringLiteral("●"), this)),
|
||||
connText_(new QLabel(this)),
|
||||
countsLabel_(new QLabel(this)),
|
||||
offlineBanner_(new QWidget(this)) {
|
||||
setWindowTitle(tr("Velox Download Manager"));
|
||||
resize(960, 560);
|
||||
|
||||
proxy_->setSourceModel(model_);
|
||||
proxy_->setDynamicSortFilter(true);
|
||||
proxy_->setSortCaseSensitivity(Qt::CaseInsensitive);
|
||||
proxy_->setFilterKeyColumn(-1);
|
||||
|
||||
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_->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: #5a3a00; color: #ffd9a0;"));
|
||||
offlineBanner_->setVisible(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_);
|
||||
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));
|
||||
|
||||
// --- status bar -----------------------------------------------------------------
|
||||
connDot_->setStyleSheet(dotStyle(QStringLiteral("#c0392b")));
|
||||
statusBar()->addPermanentWidget(countsLabel_, 1);
|
||||
statusBar()->addPermanentWidget(connText_);
|
||||
statusBar()->addPermanentWidget(connDot_);
|
||||
|
||||
// --- 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(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);
|
||||
|
||||
restoreLayout();
|
||||
onConnectionState(client_->state());
|
||||
updateCounts();
|
||||
}
|
||||
|
||||
MainWindow::~MainWindow() = default;
|
||||
|
||||
void MainWindow::closeEvent(QCloseEvent *event) {
|
||||
saveLayout();
|
||||
QMainWindow::closeEvent(event);
|
||||
event->accept();
|
||||
}
|
||||
|
||||
void MainWindow::onConnectionState(rpc::ConnectionState state) {
|
||||
connText_->setText(tr(rpc::toString(state)));
|
||||
|
||||
QString colour = QStringLiteral("#c0392b"); // red
|
||||
if (state == rpc::ConnectionState::Connected) {
|
||||
colour = QStringLiteral("#27ae60"); // green
|
||||
} else if (state != rpc::ConnectionState::Disconnected) {
|
||||
colour = QStringLiteral("#e67e22"); // amber
|
||||
}
|
||||
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."));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::onSpeedGlobal(const QJsonObject ¶ms) {
|
||||
globalDownBps_ = static_cast<qint64>(params.value("downBps").toDouble());
|
||||
updateCounts();
|
||||
}
|
||||
|
||||
void MainWindow::updateCounts() {
|
||||
const int total = model_->rowCount();
|
||||
int active = 0;
|
||||
for (int i = 0; i < total; ++i) {
|
||||
if (model_->index(i, 0).data(DownloadTableModel::StateRole).toString() ==
|
||||
QLatin1String("downloading")) {
|
||||
++active;
|
||||
}
|
||||
}
|
||||
countsLabel_->setText(
|
||||
tr("%1 downloads, %2 active ↓ %3").arg(total).arg(active).arg(humanRate(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 QModelIndex src = proxy_->mapToSource(proxyIdx);
|
||||
const QString id = model_->data(src, DownloadTableModel::TaskIdRole).toString();
|
||||
if (!id.isEmpty()) {
|
||||
ids << id;
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
void MainWindow::actOnSelection(const char *methodName) {
|
||||
const QStringList ids = selectedTaskIds();
|
||||
if (ids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
client_->call(QString::fromLatin1(methodName),
|
||||
QJsonObject{{"taskIds", QJsonArray::fromStringList(ids)}});
|
||||
}
|
||||
|
||||
void MainWindow::pauseSelection() {
|
||||
actOnSelection(rpc::method::kDownloadPause);
|
||||
}
|
||||
void MainWindow::resumeSelection() {
|
||||
actOnSelection(rpc::method::kDownloadResume);
|
||||
}
|
||||
void MainWindow::cancelSelection() {
|
||||
actOnSelection(rpc::method::kDownloadCancel);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::saveLayout() {
|
||||
QSettings settings;
|
||||
settings.setValue(QStringLiteral("mainwindow/geometry"), saveGeometry());
|
||||
settings.setValue(QStringLiteral("mainwindow/headerState"), view_->header()->saveState());
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,62 @@
|
||||
// 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).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QMainWindow>
|
||||
|
||||
#include "rpc/Protocol.hpp"
|
||||
|
||||
class QLabel;
|
||||
class QSortFilterProxyModel;
|
||||
class QTreeView;
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
class DownloadTableModel;
|
||||
namespace rpc {
|
||||
class RpcClient;
|
||||
} // namespace rpc
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MainWindow(rpc::RpcClient *client, QWidget *parent = nullptr);
|
||||
~MainWindow() override;
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
|
||||
private slots:
|
||||
void onConnectionState(rpc::ConnectionState state);
|
||||
void onSpeedGlobal(const QJsonObject ¶ms);
|
||||
void updateCounts();
|
||||
|
||||
void pauseSelection();
|
||||
void resumeSelection();
|
||||
void cancelSelection();
|
||||
|
||||
private:
|
||||
QStringList selectedTaskIds() const;
|
||||
void actOnSelection(const char *methodName);
|
||||
void restoreLayout();
|
||||
void saveLayout();
|
||||
|
||||
rpc::RpcClient *client_;
|
||||
DownloadTableModel *model_;
|
||||
QSortFilterProxyModel *proxy_;
|
||||
QTreeView *view_;
|
||||
|
||||
QLabel *connDot_;
|
||||
QLabel *connText_;
|
||||
QLabel *countsLabel_;
|
||||
QWidget *offlineBanner_;
|
||||
|
||||
qint64 globalDownBps_ = 0;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
Reference in New Issue
Block a user