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,57 @@
|
||||
# velox-gui — the Qt 6 Widgets client. Lane GUI.
|
||||
#
|
||||
# CLAUDE.md §3: zero download logic here. This target links libveloxproto (the wire
|
||||
# types, ADR 0009) and NEVER libveloxcore — a grep for curl|pwrite|sqlite under gui/
|
||||
# must come back empty.
|
||||
#
|
||||
# The root CMakeLists.txt add_subdirectory()s this unconditionally once the file exists,
|
||||
# so it must stay configurable even before CORE has landed veloxproto. Until that target
|
||||
# exists we announce and bail; the moment `core/` merges its libveloxproto, the GUI
|
||||
# lights up with no edit here (same contract as the root file's EXISTS() guards).
|
||||
|
||||
if(NOT TARGET veloxproto)
|
||||
message(STATUS "velox-gui: libveloxproto has not landed yet — GUI target skipped. "
|
||||
"It builds automatically once core/ merges the veloxproto target (ADR 0009).")
|
||||
return()
|
||||
endif()
|
||||
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
|
||||
add_executable(velox-gui
|
||||
src/main.cpp
|
||||
src/rpc/RpcConnection.cpp
|
||||
src/rpc/RpcClient.cpp
|
||||
src/models/DownloadTableModel.cpp
|
||||
src/widgets/ProgressDelegate.cpp
|
||||
src/mainwindow/MainWindow.cpp
|
||||
)
|
||||
|
||||
target_include_directories(velox-gui PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||
|
||||
target_compile_features(velox-gui PRIVATE cxx_std_23)
|
||||
|
||||
# Warnings at target scope, not via CMAKE_CXX_FLAGS — the dev/tsan presets overwrite that
|
||||
# cache variable wholesale (same rationale as core/CMakeLists.txt).
|
||||
target_compile_options(velox-gui PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
|
||||
target_link_libraries(velox-gui PRIVATE
|
||||
velox::proto
|
||||
Qt6::Widgets
|
||||
Qt6::Svg
|
||||
Qt6::Network
|
||||
)
|
||||
|
||||
set_target_properties(velox-gui PROPERTIES
|
||||
WIN32_EXECUTABLE OFF
|
||||
MACOSX_BUNDLE OFF
|
||||
)
|
||||
|
||||
# --- i18n -------------------------------------------------------------------------------
|
||||
# Every string in the GUI goes through tr(); the Arabic stub exists to prove the RTL
|
||||
# layout survives (GUI DoD). lrelease/lupdate come from Qt6::LinguistTools, found by root.
|
||||
qt_add_translations(velox-gui TS_FILES i18n/velox_ar.ts)
|
||||
|
||||
# --- tests ---------------------------------------------------------------------------
|
||||
if(VELOX_BUILD_TESTS)
|
||||
add_subdirectory(tests)
|
||||
endif()
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE TS>
|
||||
<!--
|
||||
Arabic catalogue stub. Lane GUI.
|
||||
|
||||
It exists so the RTL layout is exercised from the first commit (GUI DoD): run
|
||||
`velox-gui` with LANG=ar_EG.UTF-8, or force Qt::RightToLeft, and every screen must
|
||||
mirror cleanly. Real translations land in M5; `lupdate` fills the <message> entries
|
||||
from the tr() calls in the source.
|
||||
-->
|
||||
<TS version="2.1" language="ar" sourcelanguage="en">
|
||||
<context>
|
||||
<name>velox::gui::MainWindow</name>
|
||||
<message>
|
||||
<source>Velox Download Manager</source>
|
||||
<translation type="unfinished">مدير التنزيلات فيلوكس</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Reconnecting to the Velox service…</source>
|
||||
<translation type="unfinished"></translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>velox::gui::DownloadTableModel</name>
|
||||
<message>
|
||||
<source>File Name</source>
|
||||
<translation type="unfinished">اسم الملف</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Status</source>
|
||||
<translation type="unfinished">الحالة</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
@@ -0,0 +1,54 @@
|
||||
// velox-gui entry point. Lane GUI.
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QLibraryInfo>
|
||||
#include <QLocale>
|
||||
#include <QString>
|
||||
#include <QTranslator>
|
||||
|
||||
#include "mainwindow/MainWindow.hpp"
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "rpc/RpcClient.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
QString defaultSocketPath() {
|
||||
const QString override = qEnvironmentVariable("VELOX_SOCK");
|
||||
if (!override.isEmpty()) {
|
||||
return override;
|
||||
}
|
||||
QString runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR");
|
||||
if (runtimeDir.isEmpty()) {
|
||||
runtimeDir = QStringLiteral("/run/user/%1").arg(getuid());
|
||||
}
|
||||
return runtimeDir + QStringLiteral("/velox/velox.sock");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
QApplication app(argc, argv);
|
||||
QApplication::setApplicationName(QStringLiteral("velox-gui"));
|
||||
QApplication::setOrganizationName(QStringLiteral("velox"));
|
||||
QApplication::setApplicationVersion(QStringLiteral("0.1.0"));
|
||||
|
||||
qRegisterMetaType<velox::gui::rpc::ConnectionState>();
|
||||
qRegisterMetaType<velox::gui::rpc::RpcReply>();
|
||||
|
||||
// i18n from the first commit: load the bundled catalogue for the system locale.
|
||||
// The Arabic stub (i18n/velox_ar.ts) exists to prove the RTL layout survives.
|
||||
QTranslator translator;
|
||||
const QString locale = QLocale::system().name();
|
||||
if (translator.load(QStringLiteral("velox_") + locale, QStringLiteral(":/i18n"))) {
|
||||
app.installTranslator(&translator);
|
||||
}
|
||||
|
||||
velox::gui::rpc::RpcClient client(defaultSocketPath());
|
||||
velox::gui::MainWindow window(&client);
|
||||
window.show();
|
||||
client.start();
|
||||
|
||||
return QApplication::exec();
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,281 @@
|
||||
#include "models/DownloadTableModel.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QLocale>
|
||||
|
||||
namespace velox::gui {
|
||||
namespace {
|
||||
|
||||
QString humanBytes(qint64 n) {
|
||||
if (n < 0) {
|
||||
return QStringLiteral("—");
|
||||
}
|
||||
return QLocale().formattedDataSize(n, 2, QLocale::DataSizeIecFormat);
|
||||
}
|
||||
|
||||
QString humanRate(qint64 bps) {
|
||||
if (bps <= 0) {
|
||||
return QString();
|
||||
}
|
||||
return DownloadTableModel::tr("%1/s").arg(
|
||||
QLocale().formattedDataSize(bps, 1, QLocale::DataSizeIecFormat));
|
||||
}
|
||||
|
||||
QString humanEta(qint64 secs) {
|
||||
if (secs < 0) {
|
||||
return QString();
|
||||
}
|
||||
const qint64 h = secs / 3600;
|
||||
const qint64 m = (secs % 3600) / 60;
|
||||
const qint64 s = secs % 60;
|
||||
return QStringLiteral("%1:%2:%3")
|
||||
.arg(h, 2, 10, QLatin1Char('0'))
|
||||
.arg(m, 2, 10, QLatin1Char('0'))
|
||||
.arg(s, 2, 10, QLatin1Char('0'));
|
||||
}
|
||||
|
||||
QString stateLabel(const QString &state) {
|
||||
static const QHash<QString, QString> kLabels = {
|
||||
{QStringLiteral("new"), DownloadTableModel::tr("New")},
|
||||
{QStringLiteral("probing"), DownloadTableModel::tr("Probing")},
|
||||
{QStringLiteral("queued"), DownloadTableModel::tr("Queued")},
|
||||
{QStringLiteral("connecting"), DownloadTableModel::tr("Connecting")},
|
||||
{QStringLiteral("downloading"), DownloadTableModel::tr("Downloading")},
|
||||
{QStringLiteral("paused"), DownloadTableModel::tr("Paused")},
|
||||
{QStringLiteral("retry_wait"), DownloadTableModel::tr("Retry wait")},
|
||||
{QStringLiteral("assembling"), DownloadTableModel::tr("Assembling")},
|
||||
{QStringLiteral("verifying"), DownloadTableModel::tr("Verifying")},
|
||||
{QStringLiteral("complete"), DownloadTableModel::tr("Complete")},
|
||||
{QStringLiteral("failed"), DownloadTableModel::tr("Failed")},
|
||||
{QStringLiteral("cancelled"), DownloadTableModel::tr("Cancelled")},
|
||||
};
|
||||
return kLabels.value(state, state);
|
||||
}
|
||||
|
||||
qint64 optInt(const QJsonObject &o, const char *key, qint64 fallback) {
|
||||
const QJsonValue v = o.value(QLatin1String(key));
|
||||
return (v.isDouble()) ? static_cast<qint64>(v.toDouble()) : fallback;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DownloadTableModel::DownloadTableModel(QObject *parent) : QAbstractTableModel(parent) {}
|
||||
|
||||
int DownloadTableModel::rowCount(const QModelIndex &parent) const {
|
||||
return parent.isValid() ? 0 : static_cast<int>(rows_.size());
|
||||
}
|
||||
|
||||
int DownloadTableModel::columnCount(const QModelIndex &parent) const {
|
||||
return parent.isValid() ? 0 : ColumnCount;
|
||||
}
|
||||
|
||||
double DownloadTableModel::progressOf(const Row &r) const {
|
||||
if (r.sizeBytes <= 0) {
|
||||
return -1.0;
|
||||
}
|
||||
return std::clamp(static_cast<double>(r.downloadedBytes) / static_cast<double>(r.sizeBytes),
|
||||
0.0, 1.0);
|
||||
}
|
||||
|
||||
QVariant DownloadTableModel::data(const QModelIndex &index, int role) const {
|
||||
if (!index.isValid() || index.row() < 0 || index.row() >= rows_.size()) {
|
||||
return {};
|
||||
}
|
||||
const Row &r = rows_.at(index.row());
|
||||
|
||||
switch (role) {
|
||||
case TaskIdRole:
|
||||
return r.taskId;
|
||||
case StateRole:
|
||||
return r.state;
|
||||
case ProgressRole:
|
||||
return progressOf(r);
|
||||
case Qt::TextAlignmentRole:
|
||||
switch (index.column()) {
|
||||
case ColSize:
|
||||
case ColSpeed:
|
||||
case ColTimeLeft:
|
||||
case ColQueue:
|
||||
return static_cast<int>(Qt::AlignRight | Qt::AlignVCenter);
|
||||
default:
|
||||
return static_cast<int>(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (role != Qt::DisplayRole && role != Qt::ToolTipRole) {
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (index.column()) {
|
||||
case ColName:
|
||||
return r.filename;
|
||||
case ColQueue:
|
||||
return r.queuePosition >= 0 ? QString::number(r.queuePosition) : QString();
|
||||
case ColSize:
|
||||
return humanBytes(r.sizeBytes);
|
||||
case ColStatus:
|
||||
if (r.state == QLatin1String("downloading")) {
|
||||
const double p = progressOf(r);
|
||||
return p < 0 ? stateLabel(r.state)
|
||||
: QStringLiteral("%1 %").arg(p * 100.0, 0, 'f', 1);
|
||||
}
|
||||
return stateLabel(r.state);
|
||||
case ColTimeLeft:
|
||||
return r.state == QLatin1String("downloading") ? humanEta(r.etaSeconds) : QString();
|
||||
case ColSpeed:
|
||||
return r.state == QLatin1String("downloading") ? humanRate(r.speedBps) : QString();
|
||||
case ColLastTry:
|
||||
return r.lastTryAt;
|
||||
case ColDescription:
|
||||
return r.description.isEmpty() ? r.url : r.description;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
QVariant DownloadTableModel::headerData(int section, Qt::Orientation orientation, int role) const {
|
||||
if (orientation != Qt::Horizontal || role != Qt::DisplayRole) {
|
||||
return QAbstractTableModel::headerData(section, orientation, role);
|
||||
}
|
||||
switch (section) {
|
||||
case ColName:
|
||||
return tr("File Name");
|
||||
case ColQueue:
|
||||
return tr("Q");
|
||||
case ColSize:
|
||||
return tr("Size");
|
||||
case ColStatus:
|
||||
return tr("Status");
|
||||
case ColTimeLeft:
|
||||
return tr("Time Left");
|
||||
case ColSpeed:
|
||||
return tr("Transfer Rate");
|
||||
case ColLastTry:
|
||||
return tr("Last Try Date");
|
||||
case ColDescription:
|
||||
return tr("Description");
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Qt::ItemFlags DownloadTableModel::flags(const QModelIndex &index) const {
|
||||
if (!index.isValid()) {
|
||||
return Qt::NoItemFlags;
|
||||
}
|
||||
return Qt::ItemIsEnabled | Qt::ItemIsSelectable;
|
||||
}
|
||||
|
||||
DownloadTableModel::Row DownloadTableModel::rowFromSummary(const QJsonObject &s) {
|
||||
Row r;
|
||||
r.taskId = s.value("taskId").toString();
|
||||
r.filename = s.value("filename").toString();
|
||||
r.url = s.value("url").toString();
|
||||
r.description = s.value("description").toString();
|
||||
r.state = s.value("state").toString();
|
||||
r.lastTryAt = s.value("lastTryAt").toString();
|
||||
r.categoryId = s.value("categoryId").toString();
|
||||
r.queueId = s.value("queueId").toString();
|
||||
r.sizeBytes = optInt(s, "sizeBytes", -1);
|
||||
r.downloadedBytes = optInt(s, "downloadedBytes", 0);
|
||||
r.speedBps = optInt(s, "speedBps", 0);
|
||||
r.etaSeconds = optInt(s, "etaSeconds", -1);
|
||||
r.queuePosition = optInt(s, "queuePosition", -1);
|
||||
r.resumable = s.value("resumable").toBool();
|
||||
return r;
|
||||
}
|
||||
|
||||
void DownloadTableModel::rebuildIndex() {
|
||||
indexByTaskId_.clear();
|
||||
indexByTaskId_.reserve(rows_.size());
|
||||
for (int i = 0; i < rows_.size(); ++i) {
|
||||
indexByTaskId_.insert(rows_.at(i).taskId, i);
|
||||
}
|
||||
}
|
||||
|
||||
void DownloadTableModel::resetFromJson(const QJsonArray &items) {
|
||||
beginResetModel();
|
||||
rows_.clear();
|
||||
rows_.reserve(items.size());
|
||||
for (const QJsonValue &v : items) {
|
||||
rows_.push_back(rowFromSummary(v.toObject()));
|
||||
}
|
||||
rebuildIndex();
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
void DownloadTableModel::applyProgress(const QJsonArray &tasks) {
|
||||
for (const QJsonValue &v : tasks) {
|
||||
const QJsonObject t = v.toObject();
|
||||
const auto it = indexByTaskId_.constFind(t.value("taskId").toString());
|
||||
if (it == indexByTaskId_.cend()) {
|
||||
continue; // progress for a row we are not showing — ignore, do not synthesize
|
||||
}
|
||||
const int row = it.value();
|
||||
Row &r = rows_[row];
|
||||
r.downloadedBytes = optInt(t, "downloadedBytes", r.downloadedBytes);
|
||||
r.speedBps = optInt(t, "speedBps", r.speedBps);
|
||||
r.etaSeconds = optInt(t, "etaSeconds", -1);
|
||||
if (r.state != QLatin1String("downloading")) {
|
||||
r.state = QStringLiteral("downloading");
|
||||
}
|
||||
// Narrow patch: only the value columns move on a tick.
|
||||
emit dataChanged(index(row, ColSize), index(row, ColSpeed),
|
||||
{Qt::DisplayRole, ProgressRole});
|
||||
}
|
||||
}
|
||||
|
||||
void DownloadTableModel::applyTaskState(const QJsonObject ¶ms) {
|
||||
const QString taskId = params.value("taskId").toString();
|
||||
const QJsonObject summary = params.value("summary").toObject();
|
||||
const auto it = indexByTaskId_.constFind(taskId);
|
||||
if (it == indexByTaskId_.cend()) {
|
||||
if (!summary.isEmpty()) {
|
||||
applyTaskAdded(summary);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const int row = it.value();
|
||||
if (!summary.isEmpty()) {
|
||||
rows_[row] = rowFromSummary(summary);
|
||||
} else {
|
||||
rows_[row].state = params.value("state").toString();
|
||||
}
|
||||
emit dataChanged(index(row, 0), index(row, ColumnCount - 1));
|
||||
}
|
||||
|
||||
void DownloadTableModel::applyTaskAdded(const QJsonObject &summary) {
|
||||
const QString taskId = summary.value("taskId").toString();
|
||||
if (taskId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
const auto it = indexByTaskId_.constFind(taskId);
|
||||
if (it != indexByTaskId_.cend()) {
|
||||
const int row = it.value();
|
||||
rows_[row] = rowFromSummary(summary);
|
||||
emit dataChanged(index(row, 0), index(row, ColumnCount - 1));
|
||||
return;
|
||||
}
|
||||
const int row = static_cast<int>(rows_.size());
|
||||
beginInsertRows({}, row, row);
|
||||
rows_.push_back(rowFromSummary(summary));
|
||||
indexByTaskId_.insert(taskId, row);
|
||||
endInsertRows();
|
||||
}
|
||||
|
||||
void DownloadTableModel::applyTaskRemoved(const QString &taskId) {
|
||||
const auto it = indexByTaskId_.constFind(taskId);
|
||||
if (it == indexByTaskId_.cend()) {
|
||||
return;
|
||||
}
|
||||
const int row = it.value();
|
||||
beginRemoveRows({}, row, row);
|
||||
rows_.remove(row);
|
||||
endRemoveRows();
|
||||
rebuildIndex();
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,88 @@
|
||||
// The main download list, as a model. Lane GUI.
|
||||
//
|
||||
// docs/03-gui-spec.md §1: fed by events, never rebuilt on a progress tick. A progress
|
||||
// batch is applied as a row patch with a narrow dataChanged over just the value columns;
|
||||
// beginResetModel() is reserved for the initial load and a reconnect resync.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QAbstractTableModel>
|
||||
#include <QHash>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
class DownloadTableModel : public QAbstractTableModel {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum Column {
|
||||
ColName = 0,
|
||||
ColQueue,
|
||||
ColSize,
|
||||
ColStatus,
|
||||
ColTimeLeft,
|
||||
ColSpeed,
|
||||
ColLastTry,
|
||||
ColDescription,
|
||||
ColumnCount,
|
||||
};
|
||||
|
||||
enum Roles {
|
||||
ProgressRole = Qt::UserRole + 1, ///< double in [0,1], -1 when the size is unknown
|
||||
TaskIdRole,
|
||||
StateRole, ///< the TaskState as a lowercase string
|
||||
};
|
||||
|
||||
explicit DownloadTableModel(QObject *parent = nullptr);
|
||||
|
||||
// QAbstractItemModel
|
||||
int rowCount(const QModelIndex &parent = {}) const override;
|
||||
int columnCount(const QModelIndex &parent = {}) const override;
|
||||
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||
QVariant headerData(int section, Qt::Orientation orientation,
|
||||
int role = Qt::DisplayRole) const override;
|
||||
Qt::ItemFlags flags(const QModelIndex &index) const override;
|
||||
|
||||
public slots:
|
||||
/// Full replace (initial load / reconnect).
|
||||
void resetFromJson(const QJsonArray &items);
|
||||
/// event.task.progress: one entry per still-active task. Narrow patch, no reset.
|
||||
void applyProgress(const QJsonArray &tasks);
|
||||
/// event.task.state: carries a full TaskSummary in `summary`.
|
||||
void applyTaskState(const QJsonObject ¶ms);
|
||||
/// event.task.added.
|
||||
void applyTaskAdded(const QJsonObject &summary);
|
||||
/// event.task.removed.
|
||||
void applyTaskRemoved(const QString &taskId);
|
||||
|
||||
private:
|
||||
struct Row {
|
||||
QString taskId;
|
||||
QString filename;
|
||||
QString url;
|
||||
QString description;
|
||||
QString state; // lowercase, as on the wire
|
||||
QString lastTryAt;
|
||||
QString categoryId;
|
||||
QString queueId;
|
||||
qint64 sizeBytes = -1; // -1 == server reported no length
|
||||
qint64 downloadedBytes = 0;
|
||||
qint64 speedBps = 0;
|
||||
qint64 etaSeconds = -1; // -1 == unknown
|
||||
qint64 queuePosition = -1; // -1 == not queued
|
||||
bool resumable = false;
|
||||
};
|
||||
|
||||
static Row rowFromSummary(const QJsonObject &s);
|
||||
void rebuildIndex();
|
||||
double progressOf(const Row &r) const;
|
||||
|
||||
QVector<Row> rows_;
|
||||
QHash<QString, int> indexByTaskId_;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,87 @@
|
||||
// Shared RPC vocabulary for the GUI client. Lane GUI.
|
||||
//
|
||||
// The wire is JSON-RPC 2.0 over the Unix socket, newline-delimited (one object per line) —
|
||||
// exactly what tools/mockd and the real veloxd speak on the UDS transport. This header
|
||||
// carries only the small enums and structs the rpc/ layer passes around; the payload
|
||||
// *types* live in libveloxproto and are parsed at the model boundary.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QMetaType>
|
||||
#include <QString>
|
||||
|
||||
namespace velox::gui::rpc {
|
||||
|
||||
/// Where the connection is, for the status-bar dot and the offline banner. Anything other
|
||||
/// than Connected means the table is stale and toolbar actions should be disabled.
|
||||
enum class ConnectionState {
|
||||
Disconnected, ///< start() not called yet, or stop() was called
|
||||
Connecting, ///< TCP/UDS connect in flight
|
||||
Handshaking, ///< socket up; session.hello / session.subscribe in flight
|
||||
Connected, ///< handshake done, events flowing
|
||||
Reconnecting, ///< lost the socket, backing off before the next attempt
|
||||
};
|
||||
|
||||
/// Bare label for the status bar. Not tr()'d here — callers wrap it.
|
||||
inline const char *toString(ConnectionState s) noexcept {
|
||||
switch (s) {
|
||||
case ConnectionState::Disconnected:
|
||||
return "Disconnected";
|
||||
case ConnectionState::Connecting:
|
||||
return "Connecting";
|
||||
case ConnectionState::Handshaking:
|
||||
return "Connecting";
|
||||
case ConnectionState::Connected:
|
||||
return "Connected";
|
||||
case ConnectionState::Reconnecting:
|
||||
return "Reconnecting";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/// A JSON-RPC error object, or the absence of one (code == 0).
|
||||
struct RpcError {
|
||||
int code = 0;
|
||||
QString message;
|
||||
QJsonValue data;
|
||||
|
||||
bool isError() const noexcept { return code != 0; }
|
||||
};
|
||||
|
||||
/// The outcome of one call: exactly one of result / error is meaningful.
|
||||
struct RpcReply {
|
||||
QJsonValue result;
|
||||
RpcError error;
|
||||
|
||||
bool ok() const noexcept { return !error.isError(); }
|
||||
};
|
||||
|
||||
// --- method names ---------------------------------------------------------------------
|
||||
namespace method {
|
||||
inline constexpr auto kSessionHello = "session.hello";
|
||||
inline constexpr auto kSessionSubscribe = "session.subscribe";
|
||||
inline constexpr auto kDownloadList = "download.list";
|
||||
inline constexpr auto kDownloadGet = "download.get";
|
||||
inline constexpr auto kDownloadStart = "download.start";
|
||||
inline constexpr auto kDownloadPause = "download.pause";
|
||||
inline constexpr auto kDownloadResume = "download.resume";
|
||||
inline constexpr auto kDownloadCancel = "download.cancel";
|
||||
inline constexpr auto kDownloadRemove = "download.remove";
|
||||
} // namespace method
|
||||
|
||||
// --- event names ---------------------------------------------------------------------
|
||||
namespace event {
|
||||
inline constexpr auto kTaskAdded = "event.task.added";
|
||||
inline constexpr auto kTaskRemoved = "event.task.removed";
|
||||
inline constexpr auto kTaskState = "event.task.state";
|
||||
inline constexpr auto kTaskProgress = "event.task.progress";
|
||||
inline constexpr auto kSpeedGlobal = "event.speed.global";
|
||||
inline constexpr auto kNotify = "event.notify";
|
||||
} // namespace event
|
||||
|
||||
} // namespace velox::gui::rpc
|
||||
|
||||
Q_DECLARE_METATYPE(velox::gui::rpc::RpcReply)
|
||||
Q_DECLARE_METATYPE(velox::gui::rpc::ConnectionState)
|
||||
@@ -0,0 +1,127 @@
|
||||
#include "rpc/RpcClient.hpp"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include <QLoggingCategory>
|
||||
|
||||
#include "rpc/RpcConnection.hpp"
|
||||
|
||||
namespace velox::gui::rpc {
|
||||
|
||||
// Off by default; enable with QT_LOGGING_RULES="velox.rpc.info=true".
|
||||
Q_LOGGING_CATEGORY(lcRpc, "velox.rpc")
|
||||
|
||||
RpcClient::RpcClient(QString socketPath, QObject *parent) : QObject(parent) {
|
||||
conn_ = new RpcConnection(std::move(socketPath));
|
||||
conn_->moveToThread(&thread_);
|
||||
// QThread flushes DeferredDelete for its objects after the loop exits, so this is the
|
||||
// safe way to destroy an object that lives on the worker thread.
|
||||
connect(&thread_, &QThread::finished, conn_, &QObject::deleteLater);
|
||||
|
||||
connect(conn_, &RpcConnection::stateChanged, this, &RpcClient::onConnectionState);
|
||||
connect(conn_, &RpcConnection::notificationReceived, this, &RpcClient::onNotification);
|
||||
connect(conn_, &RpcConnection::callCompleted, this, &RpcClient::onCallCompleted);
|
||||
}
|
||||
|
||||
RpcClient::~RpcClient() {
|
||||
if (thread_.isRunning()) {
|
||||
stop(); // joins thread_; the finished -> deleteLater above disposes of conn_
|
||||
} else {
|
||||
delete conn_; // start() was never called: no loop will service deleteLater
|
||||
}
|
||||
conn_ = nullptr;
|
||||
// Nothing will ever complete the in-flight callbacks now.
|
||||
const auto pending = std::move(callbacks_);
|
||||
callbacks_.clear();
|
||||
for (const auto &cb : pending) {
|
||||
if (cb) {
|
||||
RpcReply reply;
|
||||
reply.error = {-1, QStringLiteral("client shutting down"), {}};
|
||||
cb(reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RpcClient::start() {
|
||||
if (!thread_.isRunning()) {
|
||||
thread_.start();
|
||||
}
|
||||
QMetaObject::invokeMethod(conn_, "start", Qt::QueuedConnection);
|
||||
}
|
||||
|
||||
void RpcClient::stop() {
|
||||
if (!thread_.isRunning()) {
|
||||
return;
|
||||
}
|
||||
QMetaObject::invokeMethod(conn_, "stop", Qt::QueuedConnection);
|
||||
thread_.quit();
|
||||
thread_.wait();
|
||||
}
|
||||
|
||||
void RpcClient::call(const QString &methodName, const QJsonObject ¶ms,
|
||||
std::function<void(const RpcReply &)> cb) {
|
||||
const quint64 tag = nextTag_++;
|
||||
callbacks_.insert(tag, std::move(cb));
|
||||
QMetaObject::invokeMethod(conn_, "sendCall", Qt::QueuedConnection, Q_ARG(quint64, tag),
|
||||
Q_ARG(QString, methodName), Q_ARG(QJsonObject, params));
|
||||
}
|
||||
|
||||
void RpcClient::onConnectionState(int state) {
|
||||
state_ = static_cast<ConnectionState>(state);
|
||||
qCInfo(lcRpc, "connection state: %s", toString(state_));
|
||||
emit stateChanged(state_);
|
||||
if (state_ == ConnectionState::Connected) {
|
||||
requestInitialList();
|
||||
}
|
||||
}
|
||||
|
||||
void RpcClient::requestInitialList() {
|
||||
call(QString::fromLatin1(method::kDownloadList), QJsonObject{{"limit", 1000}},
|
||||
[this](const RpcReply &reply) {
|
||||
if (!reply.ok()) {
|
||||
qCWarning(lcRpc, "download.list failed: %d %s", reply.error.code,
|
||||
qUtf8Printable(reply.error.message));
|
||||
return;
|
||||
}
|
||||
const QJsonArray items = reply.result.toObject().value("items").toArray();
|
||||
qCInfo(lcRpc, "initial download.list: %lld row(s)",
|
||||
static_cast<long long>(items.size()));
|
||||
emit taskListReset(items);
|
||||
});
|
||||
}
|
||||
|
||||
void RpcClient::onNotification(const QString &methodName, const QJsonObject ¶ms) {
|
||||
qCDebug(lcRpc, "notification: %s", qUtf8Printable(methodName));
|
||||
if (methodName == QLatin1String(event::kTaskProgress)) {
|
||||
const QJsonArray tasks = params.value("tasks").toArray();
|
||||
qCDebug(lcRpc, "progress batch: %lld task(s)", static_cast<long long>(tasks.size()));
|
||||
emit taskProgress(tasks);
|
||||
} else if (methodName == QLatin1String(event::kTaskAdded)) {
|
||||
emit taskAdded(params.value("summary").toObject());
|
||||
} else if (methodName == QLatin1String(event::kTaskState)) {
|
||||
emit taskStateChanged(params);
|
||||
} else if (methodName == QLatin1String(event::kTaskRemoved)) {
|
||||
emit taskRemoved(params.value("taskId").toString());
|
||||
} else if (methodName == QLatin1String(event::kSpeedGlobal)) {
|
||||
emit speedGlobal(params);
|
||||
} else if (methodName == QLatin1String(event::kNotify)) {
|
||||
emit notify(params);
|
||||
}
|
||||
}
|
||||
|
||||
void RpcClient::onCallCompleted(quint64 tag, const QJsonObject &envelope) {
|
||||
const auto cb = callbacks_.take(tag);
|
||||
if (!cb) {
|
||||
return;
|
||||
}
|
||||
RpcReply reply;
|
||||
if (envelope.contains("error")) {
|
||||
const QJsonObject e = envelope.value("error").toObject();
|
||||
reply.error = {e.value("code").toInt(), e.value("message").toString(), e.value("data")};
|
||||
} else {
|
||||
reply.result = envelope.value("result");
|
||||
}
|
||||
cb(reply);
|
||||
}
|
||||
|
||||
} // namespace velox::gui::rpc
|
||||
@@ -0,0 +1,73 @@
|
||||
// The main-thread face of the RPC client. Lane GUI.
|
||||
//
|
||||
// Everything the rest of the GUI touches. It owns a worker QThread with an RpcConnection
|
||||
// on it; calls are marshalled onto that thread and their replies come back as a callback
|
||||
// invoked on the main thread. Server notifications are re-emitted as typed Qt signals.
|
||||
//
|
||||
// On reaching Connected it issues the one-shot download.list itself and emits
|
||||
// taskListReset — the table never re-fetches after that, it is maintained from events
|
||||
// (docs/03-gui-spec.md §1).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include <QHash>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QThread>
|
||||
|
||||
#include "rpc/Protocol.hpp"
|
||||
|
||||
namespace velox::gui::rpc {
|
||||
|
||||
class RpcConnection;
|
||||
|
||||
class RpcClient : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit RpcClient(QString socketPath, QObject *parent = nullptr);
|
||||
~RpcClient() override;
|
||||
|
||||
ConnectionState state() const noexcept { return state_; }
|
||||
|
||||
/// Fire a JSON-RPC call. `cb` runs on the main thread; it always runs exactly once,
|
||||
/// with reply.ok() == false if the socket was down or the daemon returned an error.
|
||||
void call(const QString &methodName, const QJsonObject ¶ms,
|
||||
std::function<void(const RpcReply &)> cb = {});
|
||||
|
||||
public slots:
|
||||
void start();
|
||||
void stop();
|
||||
|
||||
signals:
|
||||
void stateChanged(velox::gui::rpc::ConnectionState state);
|
||||
/// Full replacement of the table's contents (initial load / reconnect resync).
|
||||
void taskListReset(const QJsonArray &items);
|
||||
void taskAdded(const QJsonObject &summary);
|
||||
void taskProgress(const QJsonArray &tasks);
|
||||
void taskStateChanged(const QJsonObject ¶ms);
|
||||
void taskRemoved(const QString &taskId);
|
||||
void speedGlobal(const QJsonObject ¶ms);
|
||||
void notify(const QJsonObject ¶ms);
|
||||
|
||||
private slots:
|
||||
void onConnectionState(int state);
|
||||
void onNotification(const QString &methodName, const QJsonObject ¶ms);
|
||||
void onCallCompleted(quint64 tag, const QJsonObject &envelope);
|
||||
|
||||
private:
|
||||
void requestInitialList();
|
||||
|
||||
QThread thread_;
|
||||
RpcConnection *conn_ = nullptr; // owned by thread_ affinity, deleted on thread finish
|
||||
ConnectionState state_ = ConnectionState::Disconnected;
|
||||
|
||||
quint64 nextTag_ = 1;
|
||||
QHash<quint64, std::function<void(const RpcReply &)>> callbacks_;
|
||||
};
|
||||
|
||||
} // namespace velox::gui::rpc
|
||||
@@ -0,0 +1,205 @@
|
||||
#include "rpc/RpcConnection.hpp"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QLocalSocket>
|
||||
#include <QTimer>
|
||||
|
||||
#include "rpc/Protocol.hpp"
|
||||
#include "velox_proto.hpp"
|
||||
|
||||
namespace velox::gui::rpc {
|
||||
namespace {
|
||||
|
||||
constexpr int kStateDisconnected = int(ConnectionState::Disconnected);
|
||||
constexpr int kStateConnecting = int(ConnectionState::Connecting);
|
||||
constexpr int kStateHandshaking = int(ConnectionState::Handshaking);
|
||||
constexpr int kStateConnected = int(ConnectionState::Connected);
|
||||
constexpr int kStateReconnecting = int(ConnectionState::Reconnecting);
|
||||
|
||||
QJsonObject makeErrorEnvelope(int code, const QString &message) {
|
||||
QJsonObject err{{"code", code}, {"message", message}};
|
||||
return QJsonObject{{"jsonrpc", "2.0"}, {"error", err}};
|
||||
}
|
||||
|
||||
QString contractProtocolVersion() {
|
||||
const auto v = velox::proto::kProtocolVersion;
|
||||
return QString::fromUtf8(v.data(), static_cast<qsizetype>(v.size()));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RpcConnection::RpcConnection(QString socketPath, QObject *parent)
|
||||
: QObject(parent), socketPath_(std::move(socketPath)) {
|
||||
socket_ = new QLocalSocket(this);
|
||||
connect(socket_, &QLocalSocket::connected, this, &RpcConnection::onConnected);
|
||||
connect(socket_, &QLocalSocket::disconnected, this, &RpcConnection::onDisconnected);
|
||||
connect(socket_, &QLocalSocket::errorOccurred, this, &RpcConnection::onErrorOccurred);
|
||||
connect(socket_, &QLocalSocket::readyRead, this, &RpcConnection::onReadyRead);
|
||||
|
||||
reconnectTimer_ = new QTimer(this);
|
||||
reconnectTimer_->setSingleShot(true);
|
||||
connect(reconnectTimer_, &QTimer::timeout, this, &RpcConnection::attemptReconnect);
|
||||
}
|
||||
|
||||
RpcConnection::~RpcConnection() = default;
|
||||
|
||||
void RpcConnection::start() {
|
||||
if (wantConnected_) {
|
||||
return;
|
||||
}
|
||||
wantConnected_ = true;
|
||||
backoffMs_ = kMinBackoffMs;
|
||||
setState(kStateConnecting);
|
||||
socket_->connectToServer(socketPath_);
|
||||
}
|
||||
|
||||
void RpcConnection::stop() {
|
||||
wantConnected_ = false;
|
||||
reconnectTimer_->stop();
|
||||
socket_->abort();
|
||||
rxBuffer_.clear();
|
||||
// Fail any in-flight callbacks so RpcClient does not leak them.
|
||||
const auto tags = pendingTags_.values();
|
||||
pendingTags_.clear();
|
||||
for (const quint64 tag : tags) {
|
||||
emit callCompleted(tag, makeErrorEnvelope(-1, QStringLiteral("connection stopped")));
|
||||
}
|
||||
setState(kStateDisconnected);
|
||||
}
|
||||
|
||||
void RpcConnection::sendCall(quint64 tag, const QString &methodName, const QJsonObject ¶ms) {
|
||||
if (state_ != kStateConnected) {
|
||||
emit callCompleted(tag, makeErrorEnvelope(-1, QStringLiteral("not connected")));
|
||||
return;
|
||||
}
|
||||
const qint64 id = nextRpcId_++;
|
||||
pendingTags_.insert(id, tag);
|
||||
sendRaw(id, methodName, params);
|
||||
}
|
||||
|
||||
void RpcConnection::onConnected() {
|
||||
rxBuffer_.clear();
|
||||
beginHandshake();
|
||||
}
|
||||
|
||||
void RpcConnection::beginHandshake() {
|
||||
setState(kStateHandshaking);
|
||||
sendRaw(kHelloId, QString::fromLatin1(method::kSessionHello),
|
||||
QJsonObject{{"clientType", "gui"},
|
||||
{"clientName", QStringLiteral("velox-gui 0.1.0")},
|
||||
{"protocolVersion", contractProtocolVersion()}});
|
||||
}
|
||||
|
||||
// A dropped connection surfaces as disconnected(); a *failed* connect attempt surfaces as
|
||||
// errorOccurred() with no disconnected() to follow. Both funnel here, and the isActive()
|
||||
// guard means the two firing together for one failure only arms one timer.
|
||||
void RpcConnection::scheduleReconnect() {
|
||||
if (!wantConnected_) {
|
||||
setState(kStateDisconnected);
|
||||
return;
|
||||
}
|
||||
if (reconnectTimer_->isActive()) {
|
||||
return;
|
||||
}
|
||||
setState(kStateReconnecting);
|
||||
reconnectTimer_->start(backoffMs_);
|
||||
backoffMs_ = qMin(backoffMs_ * 2, kMaxBackoffMs);
|
||||
}
|
||||
|
||||
void RpcConnection::onDisconnected() {
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
void RpcConnection::onErrorOccurred() {
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
void RpcConnection::attemptReconnect() {
|
||||
if (!wantConnected_) {
|
||||
return;
|
||||
}
|
||||
setState(kStateConnecting);
|
||||
socket_->abort();
|
||||
socket_->connectToServer(socketPath_);
|
||||
}
|
||||
|
||||
void RpcConnection::onReadyRead() {
|
||||
rxBuffer_ += socket_->readAll();
|
||||
int nl = rxBuffer_.indexOf('\n');
|
||||
while (nl != -1) {
|
||||
const QByteArray line = rxBuffer_.left(nl).trimmed();
|
||||
rxBuffer_.remove(0, nl + 1);
|
||||
nl = rxBuffer_.indexOf('\n');
|
||||
if (line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
QJsonParseError perr{};
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(line, &perr);
|
||||
if (perr.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
continue; // a malformed frame is dropped, never thrown
|
||||
}
|
||||
dispatchFrame(doc.object());
|
||||
}
|
||||
}
|
||||
|
||||
void RpcConnection::dispatchFrame(const QJsonObject &frame) {
|
||||
const QJsonValue idVal = frame.value("id");
|
||||
const bool isResponse = frame.contains("result") || frame.contains("error");
|
||||
|
||||
if (idVal.isDouble() && isResponse) {
|
||||
const qint64 id = static_cast<qint64>(idVal.toDouble());
|
||||
if (id == kHelloId) {
|
||||
if (frame.contains("error")) {
|
||||
socket_->abort(); // version mismatch or refused — bounce and retry
|
||||
return;
|
||||
}
|
||||
sendRaw(kSubscribeId, QString::fromLatin1(method::kSessionSubscribe),
|
||||
QJsonObject{{"events", QJsonArray{event::kTaskAdded, event::kTaskRemoved,
|
||||
event::kTaskState, event::kTaskProgress,
|
||||
event::kSpeedGlobal, event::kNotify}}});
|
||||
return;
|
||||
}
|
||||
if (id == kSubscribeId) {
|
||||
if (frame.contains("error")) {
|
||||
socket_->abort();
|
||||
return;
|
||||
}
|
||||
backoffMs_ = kMinBackoffMs;
|
||||
setState(kStateConnected);
|
||||
return;
|
||||
}
|
||||
const auto it = pendingTags_.constFind(id);
|
||||
if (it != pendingTags_.cend()) {
|
||||
const quint64 tag = it.value();
|
||||
pendingTags_.erase(it);
|
||||
emit callCompleted(tag, frame);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const QString methodName = frame.value("method").toString();
|
||||
if (!methodName.isEmpty()) {
|
||||
emit notificationReceived(methodName, frame.value("params").toObject());
|
||||
}
|
||||
}
|
||||
|
||||
void RpcConnection::sendRaw(qint64 rpcId, const QString &methodName, const QJsonObject ¶ms) {
|
||||
const QJsonObject req{{"jsonrpc", "2.0"},
|
||||
{"id", static_cast<double>(rpcId)},
|
||||
{"method", methodName},
|
||||
{"params", params}};
|
||||
QByteArray line = QJsonDocument(req).toJson(QJsonDocument::Compact);
|
||||
line += '\n';
|
||||
socket_->write(line);
|
||||
}
|
||||
|
||||
void RpcConnection::setState(int state) {
|
||||
if (state_ == state) {
|
||||
return;
|
||||
}
|
||||
state_ = state;
|
||||
emit stateChanged(state);
|
||||
}
|
||||
|
||||
} // namespace velox::gui::rpc
|
||||
@@ -0,0 +1,76 @@
|
||||
// The socket end of the RPC client. Lane GUI.
|
||||
//
|
||||
// Affined to a worker thread (see RpcClient). Owns the QLocalSocket, does line framing,
|
||||
// drives the session.hello / session.subscribe handshake, and reconnects with exponential
|
||||
// backoff. It never touches a widget: everything out of here is a queued signal.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QHash>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
class QLocalSocket;
|
||||
class QTimer;
|
||||
|
||||
namespace velox::gui::rpc {
|
||||
|
||||
class RpcConnection : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit RpcConnection(QString socketPath, QObject *parent = nullptr);
|
||||
~RpcConnection() override;
|
||||
|
||||
public slots:
|
||||
/// Begin connecting and keep the socket up until stop(). Idempotent.
|
||||
void start();
|
||||
/// Tear the socket down and cancel any pending reconnect. Idempotent.
|
||||
void stop();
|
||||
/// Enqueue one JSON-RPC call. `tag` is echoed back on callCompleted so RpcClient can
|
||||
/// match it to a callback. Calls made while disconnected are dropped (ok() == false).
|
||||
void sendCall(quint64 tag, const QString &methodName, const QJsonObject ¶ms);
|
||||
|
||||
signals:
|
||||
/// int is a ConnectionState; kept as int so the cross-thread queued connection needs
|
||||
/// no custom metatype registration.
|
||||
void stateChanged(int state);
|
||||
void notificationReceived(const QString &methodName, const QJsonObject ¶ms);
|
||||
void callCompleted(quint64 tag, const QJsonObject &envelope);
|
||||
|
||||
private slots:
|
||||
void onConnected();
|
||||
void onDisconnected();
|
||||
void onErrorOccurred();
|
||||
void onReadyRead();
|
||||
void attemptReconnect();
|
||||
|
||||
private:
|
||||
void setState(int state);
|
||||
void scheduleReconnect();
|
||||
void sendRaw(qint64 rpcId, const QString &methodName, const QJsonObject ¶ms);
|
||||
void dispatchFrame(const QJsonObject &frame);
|
||||
void beginHandshake();
|
||||
|
||||
QString socketPath_;
|
||||
QLocalSocket *socket_ = nullptr;
|
||||
QTimer *reconnectTimer_ = nullptr;
|
||||
QByteArray rxBuffer_;
|
||||
|
||||
bool wantConnected_ = false; ///< start() was called and stop() has not
|
||||
int state_ = 0; ///< ConnectionState
|
||||
int backoffMs_ = kMinBackoffMs;
|
||||
|
||||
qint64 nextRpcId_ = 100;
|
||||
// rpc id -> caller tag, for regular calls only. Handshake ids are handled inline.
|
||||
QHash<qint64, quint64> pendingTags_;
|
||||
|
||||
static constexpr qint64 kHelloId = 1;
|
||||
static constexpr qint64 kSubscribeId = 2;
|
||||
static constexpr int kMinBackoffMs = 250;
|
||||
static constexpr int kMaxBackoffMs = 8000;
|
||||
};
|
||||
|
||||
} // namespace velox::gui::rpc
|
||||
@@ -0,0 +1,44 @@
|
||||
#include "widgets/ProgressDelegate.hpp"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QPainter>
|
||||
#include <QStyleOptionProgressBar>
|
||||
|
||||
#include "models/DownloadTableModel.hpp"
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
void ProgressDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option,
|
||||
const QModelIndex &index) const {
|
||||
const QVariant progressVar = index.data(DownloadTableModel::ProgressRole);
|
||||
const double progress = progressVar.isValid() ? progressVar.toDouble() : -1.0;
|
||||
|
||||
if (progress < 0.0) {
|
||||
QStyledItemDelegate::paint(painter, option, index);
|
||||
return;
|
||||
}
|
||||
|
||||
QStyleOptionViewItem opt(option);
|
||||
initStyleOption(&opt, index);
|
||||
// We draw the bar ourselves; suppress the default text so it is not painted twice.
|
||||
opt.text.clear();
|
||||
QStyle *style = opt.widget ? opt.widget->style() : QApplication::style();
|
||||
style->drawControl(QStyle::CE_ItemViewItem, &opt, painter, opt.widget);
|
||||
|
||||
QStyleOptionProgressBar bar;
|
||||
bar.state = option.state | QStyle::State_Horizontal;
|
||||
bar.direction = option.direction;
|
||||
bar.rect = option.rect.adjusted(3, 3, -3, -3);
|
||||
bar.fontMetrics = option.fontMetrics;
|
||||
bar.palette = option.palette;
|
||||
bar.minimum = 0;
|
||||
bar.maximum = 10000;
|
||||
bar.progress = static_cast<int>(progress * 10000.0);
|
||||
bar.textVisible = true;
|
||||
bar.textAlignment = Qt::AlignCenter;
|
||||
bar.text = index.data(Qt::DisplayRole).toString();
|
||||
|
||||
style->drawControl(QStyle::CE_ProgressBar, &bar, painter, opt.widget);
|
||||
}
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,23 @@
|
||||
// In-cell progress bar for the Status column. Lane GUI.
|
||||
//
|
||||
// docs/03-gui-spec.md §1: "Progress bar drawn by a QStyledItemDelegate in the Status
|
||||
// column." Reads DownloadTableModel::ProgressRole; falls back to plain text for rows with
|
||||
// no measurable progress (unknown size, or a non-downloading state).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QStyledItemDelegate>
|
||||
|
||||
namespace velox::gui {
|
||||
|
||||
class ProgressDelegate : public QStyledItemDelegate {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
using QStyledItemDelegate::QStyledItemDelegate;
|
||||
|
||||
void paint(QPainter *painter, const QStyleOptionViewItem &option,
|
||||
const QModelIndex &index) const override;
|
||||
};
|
||||
|
||||
} // namespace velox::gui
|
||||
@@ -0,0 +1,22 @@
|
||||
# GUI unit tests. Lane GUI.
|
||||
#
|
||||
# CLAUDE.md §5: a feature with no test does not exist. The model is the piece with real
|
||||
# logic that can be tested headless — the progress patch must be a narrow dataChanged and
|
||||
# must never reset the model (docs/03-gui-spec.md §1).
|
||||
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
|
||||
find_package(Qt6 REQUIRED COMPONENTS Test)
|
||||
|
||||
add_executable(tst_downloadtablemodel
|
||||
tst_downloadtablemodel.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/../src/models/DownloadTableModel.cpp
|
||||
)
|
||||
|
||||
target_include_directories(tst_downloadtablemodel PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../src)
|
||||
target_compile_features(tst_downloadtablemodel PRIVATE cxx_std_23)
|
||||
target_compile_options(tst_downloadtablemodel PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
||||
target_link_libraries(tst_downloadtablemodel PRIVATE Qt6::Core Qt6::Test)
|
||||
|
||||
add_test(NAME gui_downloadtablemodel COMMAND tst_downloadtablemodel)
|
||||
set_tests_properties(gui_downloadtablemodel PROPERTIES LABELS "gui")
|
||||
@@ -0,0 +1,112 @@
|
||||
// DownloadTableModel unit tests. Lane GUI.
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QSignalSpy>
|
||||
#include <QtTest>
|
||||
|
||||
#include "models/DownloadTableModel.hpp"
|
||||
|
||||
using velox::gui::DownloadTableModel;
|
||||
|
||||
namespace {
|
||||
|
||||
QJsonObject summary(const QString &id, const QString &state, qint64 size, qint64 done) {
|
||||
return QJsonObject{
|
||||
{"taskId", id},
|
||||
{"filename", id + ".iso"},
|
||||
{"url", "https://example.test/" + id},
|
||||
{"state", state},
|
||||
{"sizeBytes", double(size)},
|
||||
{"downloadedBytes", double(done)},
|
||||
{"speedBps", 0.0},
|
||||
{"segments", 1.0},
|
||||
{"resumable", true},
|
||||
{"createdAt", "2026-09-10T00:00:00Z"},
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TstDownloadTableModel : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void resetPopulatesRows();
|
||||
void progressPatchIsNarrowAndDoesNotReset();
|
||||
void progressForUnknownTaskIsIgnored();
|
||||
void addThenRemove();
|
||||
};
|
||||
|
||||
void TstDownloadTableModel::resetPopulatesRows() {
|
||||
DownloadTableModel model;
|
||||
model.resetFromJson(
|
||||
QJsonArray{summary("a", "downloading", 1000, 500), summary("b", "paused", 2000, 0)});
|
||||
|
||||
QCOMPARE(model.rowCount(), 2);
|
||||
QCOMPARE(model.index(0, DownloadTableModel::ColName).data().toString(),
|
||||
QStringLiteral("a.iso"));
|
||||
QCOMPARE(model.index(0, DownloadTableModel::ColStatus).data().toString(),
|
||||
QStringLiteral("50.0 %"));
|
||||
QCOMPARE(model.index(0, 0).data(DownloadTableModel::ProgressRole).toDouble(), 0.5);
|
||||
}
|
||||
|
||||
void TstDownloadTableModel::progressPatchIsNarrowAndDoesNotReset() {
|
||||
DownloadTableModel model;
|
||||
model.resetFromJson(QJsonArray{summary("a", "downloading", 1000, 100)});
|
||||
|
||||
QSignalSpy dataChangedSpy(&model, &QAbstractItemModel::dataChanged);
|
||||
QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset);
|
||||
QSignalSpy rowsInsertedSpy(&model, &QAbstractItemModel::rowsInserted);
|
||||
|
||||
model.applyProgress(QJsonArray{QJsonObject{
|
||||
{"taskId", "a"}, {"downloadedBytes", 600.0}, {"speedBps", 50.0}, {"etaSeconds", 8.0}}});
|
||||
|
||||
QCOMPARE(resetSpy.count(), 0);
|
||||
QCOMPARE(rowsInsertedSpy.count(), 0);
|
||||
QCOMPARE(dataChangedSpy.count(), 1);
|
||||
|
||||
const auto args = dataChangedSpy.takeFirst();
|
||||
const auto topLeft = args.at(0).toModelIndex();
|
||||
const auto bottomRight = args.at(1).toModelIndex();
|
||||
QCOMPARE(topLeft.row(), 0);
|
||||
QCOMPARE(bottomRight.row(), 0);
|
||||
QCOMPARE(topLeft.column(), int(DownloadTableModel::ColSize));
|
||||
QCOMPARE(bottomRight.column(), int(DownloadTableModel::ColSpeed));
|
||||
// The whole row must NOT be in the patch — Name/Queue stay untouched on a tick.
|
||||
QVERIFY(topLeft.column() > int(DownloadTableModel::ColName));
|
||||
|
||||
QCOMPARE(model.index(0, DownloadTableModel::ColStatus).data().toString(),
|
||||
QStringLiteral("60.0 %"));
|
||||
}
|
||||
|
||||
void TstDownloadTableModel::progressForUnknownTaskIsIgnored() {
|
||||
DownloadTableModel model;
|
||||
model.resetFromJson(QJsonArray{summary("a", "downloading", 1000, 100)});
|
||||
|
||||
QSignalSpy dataChangedSpy(&model, &QAbstractItemModel::dataChanged);
|
||||
model.applyProgress(QJsonArray{
|
||||
QJsonObject{{"taskId", "ghost"}, {"downloadedBytes", 999.0}, {"speedBps", 1.0}}});
|
||||
QCOMPARE(dataChangedSpy.count(), 0);
|
||||
QCOMPARE(model.rowCount(), 1);
|
||||
}
|
||||
|
||||
void TstDownloadTableModel::addThenRemove() {
|
||||
DownloadTableModel model;
|
||||
model.resetFromJson(QJsonArray{summary("a", "downloading", 1000, 100)});
|
||||
|
||||
QSignalSpy insertedSpy(&model, &QAbstractItemModel::rowsInserted);
|
||||
model.applyTaskAdded(summary("c", "connecting", 4000, 0));
|
||||
QCOMPARE(insertedSpy.count(), 1);
|
||||
QCOMPARE(model.rowCount(), 2);
|
||||
|
||||
QSignalSpy removedSpy(&model, &QAbstractItemModel::rowsRemoved);
|
||||
model.applyTaskRemoved(QStringLiteral("a"));
|
||||
QCOMPARE(removedSpy.count(), 1);
|
||||
QCOMPARE(model.rowCount(), 1);
|
||||
QCOMPARE(model.index(0, 0).data(DownloadTableModel::TaskIdRole).toString(),
|
||||
QStringLiteral("c"));
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(TstDownloadTableModel)
|
||||
#include "tst_downloadtablemodel.moc"
|
||||
Reference in New Issue
Block a user