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,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
|
||||
Reference in New Issue
Block a user