Files
vdm/gui/src/models/DownloadTableModel.cpp
T
samiandClaude Sonnet 5 51fa1201bd gui: category tree, menus, toolbar, splitter — build-order step 3
- CategoryPanel: the left tree — All Downloads / Unfinished / Finished,
  then Categories and Queues populated from category.list / queue.list,
  with per-node task counts. Selecting a node emits a TaskSelection.
- DownloadFilterProxy: QSortFilterProxyModel keyed off that selection.
  Client-side for M1 (the whole list fits); asTaskFilter() exposes the
  equivalent TaskFilter for a server-side download.list once paging lands.
- MainWindow: menu bar (Tasks / Downloads / View / Help) sharing QAction
  objects with the toolbar; QSplitter [panel | table]; Delete with a
  confirm; Resume/Pause All; View menu toggles the panel. Actions
  disabled while offline.
- Counts are computed off a throttled 400 ms timer, not the 4 Hz progress
  path. Fixed a debounce-vs-throttle bug found in the first screenshot:
  restarting the timer on every progress tick meant it never fired and
  the status bar sat at "0 of 0 downloads".
- First-run column widths that fit the content.
- tst_downloadfilterproxy: nodes filter to their own rows, the
  Finished/Unfinished split is correct, and the filter is dynamic (a row
  that finishes leaves the Unfinished node with no re-list). Verified
  against mockd --tasks 400 (screenshot: tree counts 75/84/89/83/69 sum
  to 400, status bar "400 of 400, 21 active").

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016Ne28kx4VreeBWZv82Nksd
2026-09-10 15:41:29 +04:00

286 lines
9.4 KiB
C++

#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 CategoryIdRole:
return r.categoryId;
case QueueIdRole:
return r.queueId;
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 &params) {
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