From 257a19caaaa3a31b0890e3eeb728ef9e19f28ede Mon Sep 17 00:00:00 2001 From: Mick Sayson Date: Sat, 25 Sep 2021 13:19:02 -0700 Subject: [PATCH] feat(filesform): Add in progress transfers to files form As part of #1532 it was identified that long running file transfers could get lost deep in the chatlog. This could result in unexpected use of bandwidth over time if users lose track of old/large transfers. This commit updates the files form to show in progress file transfers and offer a way to control them. * FilesForm now works on ToxFiles instead of finished file paths * FilesForm widgets have been replaced with an MV tree view with depth 1. The existing QListWidget did not provide us the controls to render more complex items. The use of delegates allows us to efficiently draw progress bars and controls * getHumanReadableSize has been extracted from FileTransferWidget into a more general utils file --- cmake/Testing.cmake | 1 + res.qrc | 6 + src/chatlog/content/filetransferwidget.cpp | 13 +- src/chatlog/content/filetransferwidget.h | 2 - src/core/corefile.cpp | 2 - src/core/corefile.h | 2 - src/model/notificationgenerator.cpp | 3 +- src/widget/form/filesform.cpp | 461 +++++++++++++++++- src/widget/form/filesform.h | 96 +++- src/widget/widget.cpp | 8 +- test/widget/filesform_test.cpp | 282 +++++++++++ .../dark/fileTransferInstance/arrow_black.svg | 44 ++ themes/dark/fileTransferInstance/no_dark.svg | 47 ++ .../dark/fileTransferInstance/pause_dark.svg | 58 +++ .../fileTransferInstance/arrow_black.svg | 44 ++ .../default/fileTransferInstance/no_dark.svg | 47 ++ .../fileTransferInstance/pause_dark.svg | 58 +++ util/CMakeLists.txt | 10 +- util/include/util/display.h | 26 + util/src/display.cpp | 34 ++ 20 files changed, 1188 insertions(+), 56 deletions(-) create mode 100644 test/widget/filesform_test.cpp create mode 100644 themes/dark/fileTransferInstance/arrow_black.svg create mode 100644 themes/dark/fileTransferInstance/no_dark.svg create mode 100644 themes/dark/fileTransferInstance/pause_dark.svg create mode 100644 themes/default/fileTransferInstance/arrow_black.svg create mode 100644 themes/default/fileTransferInstance/no_dark.svg create mode 100644 themes/default/fileTransferInstance/pause_dark.svg create mode 100644 util/include/util/display.h create mode 100644 util/src/display.cpp diff --git a/cmake/Testing.cmake b/cmake/Testing.cmake index 8c421862c..73083cde5 100644 --- a/cmake/Testing.cmake +++ b/cmake/Testing.cmake @@ -57,6 +57,7 @@ auto_test(model messageprocessor "") auto_test(model sessionchatlog "") auto_test(model exiftransform "") auto_test(model notificationgenerator "${MOCK_SOURCES}") +auto_test(widget filesform "") if (UNIX) auto_test(platform posixsignalnotifier "") diff --git a/res.qrc b/res.qrc index bb20bf79f..a99936fbf 100644 --- a/res.qrc +++ b/res.qrc @@ -100,10 +100,13 @@ themes/dark/chatArea/typing.svg themes/dark/chatArea/error.svg themes/dark/fileTransferInstance/no.svg + themes/dark/fileTransferInstance/no_dark.svg themes/dark/fileTransferInstance/pause.svg + themes/dark/fileTransferInstance/pause_dark.svg themes/dark/fileTransferInstance/yes.svg themes/dark/fileTransferInstance/dir.svg themes/dark/fileTransferInstance/arrow_white.svg + themes/dark/fileTransferInstance/arrow_black.svg themes/dark/fileTransferInstance/browse.svg themes/dark/fileTransferInstance/filetransferWidget.css themes/dark/genericChatForm/genericChatForm.css @@ -164,10 +167,13 @@ themes/default/chatArea/typing.svg themes/default/chatArea/error.svg themes/default/fileTransferInstance/no.svg + themes/default/fileTransferInstance/no_dark.svg themes/default/fileTransferInstance/pause.svg + themes/default/fileTransferInstance/pause_dark.svg themes/default/fileTransferInstance/yes.svg themes/default/fileTransferInstance/dir.svg themes/default/fileTransferInstance/arrow_white.svg + themes/default/fileTransferInstance/arrow_black.svg themes/default/fileTransferInstance/browse.svg themes/default/fileTransferInstance/filetransferWidget.css themes/default/genericChatForm/genericChatForm.css diff --git a/src/chatlog/content/filetransferwidget.cpp b/src/chatlog/content/filetransferwidget.cpp index fd80ec6e2..411dad209 100644 --- a/src/chatlog/content/filetransferwidget.cpp +++ b/src/chatlog/content/filetransferwidget.cpp @@ -26,6 +26,7 @@ #include "src/widget/style.h" #include "src/widget/widget.h" #include "src/model/exiftransform.h" +#include "util/display.h" #include #include @@ -234,18 +235,6 @@ void FileTransferWidget::reloadTheme() updateBackgroundColor(lastStatus); } -QString FileTransferWidget::getHumanReadableSize(qint64 size) -{ - static const char* suffix[] = {"B", "KiB", "MiB", "GiB", "TiB"}; - int exp = 0; - - if (size > 0) { - exp = std::min(static_cast(log(size) / log(1024)), static_cast(sizeof(suffix) / sizeof(suffix[0]) - 1)); - } - - return QString().setNum(size / pow(1024, exp), 'f', exp > 1 ? 2 : 0).append(suffix[exp]); -} - void FileTransferWidget::updateWidgetColor(ToxFile const& file) { if (lastStatus == file.status) { diff --git a/src/chatlog/content/filetransferwidget.h b/src/chatlog/content/filetransferwidget.h index 50244afe2..3b639e836 100644 --- a/src/chatlog/content/filetransferwidget.h +++ b/src/chatlog/content/filetransferwidget.h @@ -42,8 +42,6 @@ public: explicit FileTransferWidget(QWidget* parent, CoreFile& _coreFile, ToxFile file); virtual ~FileTransferWidget(); bool isActive() const; - static QString getHumanReadableSize(qint64 size); - void onFileTransferUpdate(ToxFile file); protected: diff --git a/src/core/corefile.cpp b/src/core/corefile.cpp index 7c0114357..82ce93f08 100644 --- a/src/core/corefile.cpp +++ b/src/core/corefile.cpp @@ -448,7 +448,6 @@ void CoreFile::onFileDataCallback(Tox* tox, uint32_t friendId, uint32_t fileId, file->status = ToxFile::FINISHED; if (file->fileKind != TOX_FILE_KIND_AVATAR) { emit coreFile->fileTransferFinished(*file); - emit coreFile->fileUploadFinished(file->filePath); } coreFile->removeFile(friendId, fileId); return; @@ -519,7 +518,6 @@ void CoreFile::onFileRecvChunkCallback(Tox* tox, uint32_t friendId, uint32_t fil } } else { emit coreFile->fileTransferFinished(*file); - emit coreFile->fileDownloadFinished(file->filePath); } coreFile->removeFile(friendId, fileId); return; diff --git a/src/core/corefile.h b/src/core/corefile.h index 062eba913..3085ac007 100644 --- a/src/core/corefile.h +++ b/src/core/corefile.h @@ -69,8 +69,6 @@ signals: void fileTransferAccepted(ToxFile file); void fileTransferCancelled(ToxFile file); void fileTransferFinished(ToxFile file); - void fileUploadFinished(const QString& path); - void fileDownloadFinished(const QString& path); void fileTransferPaused(ToxFile file); void fileTransferInfo(ToxFile file); void fileTransferRemotePausedUnpaused(ToxFile file, bool paused); diff --git a/src/model/notificationgenerator.cpp b/src/model/notificationgenerator.cpp index 83f8fd9a2..f25228ad1 100644 --- a/src/model/notificationgenerator.cpp +++ b/src/model/notificationgenerator.cpp @@ -19,6 +19,7 @@ #include "notificationgenerator.h" #include "src/chatlog/content/filetransferwidget.h" +#include "util/display.h" #include @@ -223,7 +224,7 @@ NotificationData NotificationGenerator::fileTransferNotification(const Friend* f { //: e.g. Bob - file transfer ret.title = tr("%1 - file transfer").arg(f->getDisplayedName()); - ret.message = filename + " (" + FileTransferWidget::getHumanReadableSize(fileSize) + ")"; + ret.message = filename + " (" + getHumanReadableSize(fileSize) + ")"; } ret.pixmap = getSenderAvatar(profile, f->getPublicKey()); diff --git a/src/widget/form/filesform.cpp b/src/widget/form/filesform.cpp index 2778c5812..be29f8790 100644 --- a/src/widget/form/filesform.cpp +++ b/src/widget/form/filesform.cpp @@ -18,16 +18,410 @@ */ #include "filesform.h" +#include "src/core/toxfile.h" #include "src/widget/contentlayout.h" #include "src/widget/translator.h" #include "src/widget/style.h" #include "src/widget/widget.h" +#include "src/friendlist.h" +#include "util/display.h" #include #include +#include +#include +#include +#include +#include +#include -FilesForm::FilesForm() +namespace { + QRect pauseRect(const QStyleOptionViewItem& option) + { + float controlSize = option.rect.height() * 0.8f; + float rectWidth = option.rect.width(); + float buttonHorizontalArea = rectWidth / 2; + + // To center the button, we find the horizontal center and subtract half + // our width from it + int buttonXPos = std::round(option.rect.x() + buttonHorizontalArea / 2 - controlSize / 2); + int buttonYPos = std::round(option.rect.y() + option.rect.height() * 0.1f); + return QRect(buttonXPos, buttonYPos, controlSize, controlSize); + } + + QRect stopRect(const QStyleOptionViewItem& option) + { + float controlSize = option.rect.height() * 0.8; + float rectWidth = option.rect.width(); + float buttonHorizontalArea = rectWidth / 2; + + // To center the button, we find the horizontal center and subtract half + // our width from it + int buttonXPos = std::round(option.rect.x() + buttonHorizontalArea + buttonHorizontalArea / 2 - controlSize / 2); + int buttonYPos = std::round(option.rect.y() + option.rect.height() * 0.1f); + return QRect(buttonXPos, buttonYPos, controlSize, controlSize); + } + + QString fileStatusString(ToxFile file) + { + switch (file.status) + { + case ToxFile::INITIALIZING: + return QObject::tr("Initializing"); + case ToxFile::TRANSMITTING: + return QObject::tr("Transmitting"); + case ToxFile::FINISHED: + return QObject::tr("Finished"); + case ToxFile::BROKEN: + return QObject::tr("Broken"); + case ToxFile::CANCELED: + return QObject::tr("Canceled"); + case ToxFile::PAUSED: + if (file.pauseStatus.localPaused()) { + return QObject::tr("Paused"); + } else { + return QObject::tr("Remote paused"); + } + } + + qWarning("Corrupt file status %d", file.status); + return ""; + } + + bool fileTransferFailed(const ToxFile::FileStatus& status) { + switch (status) + { + case ToxFile::INITIALIZING: + case ToxFile::PAUSED: + case ToxFile::TRANSMITTING: + case ToxFile::FINISHED: + return false; + case ToxFile::BROKEN: + case ToxFile::CANCELED: + return true; + } + + qWarning("Invalid file status: %d", status); + return true; + } + + bool shouldProcessFileKind(uint8_t inKind) + { + auto kind = static_cast(inKind); + + switch (kind) + { + case TOX_FILE_KIND_DATA: return true; + // Avatar sharing should be seamless, the user does not need to see + // these in their file transfer list. + case TOX_FILE_KIND_AVATAR: return false; + } + + qWarning("Unexpected file kind %d", kind); + return false; + } + +} // namespace + +namespace FileTransferList +{ + Column toFileTransferListColumn(int in) { + if (in >= 0 && in < static_cast(Column::invalid)) { + return static_cast(in); + } + + qWarning("Invalid file transfer list column %d", in); + return Column::invalid; + } + + QString toQString(Column column) { + switch (column) + { + case Column::fileName: + return QObject::tr("File Name"); + case Column::contact: + return QObject::tr("Contact"); + case Column::progress: + return QObject::tr("Progress"); + case Column::size: + return QObject::tr("Size"); + case Column::speed: + return QObject::tr("Speed"); + case Column::status: + return QObject::tr("Status"); + case Column::control: + return QObject::tr("Control"); + case Column::invalid: + break; + } + + return ""; + } + + EditorAction toEditorAction(int in) { + if (in < 0 || in >= static_cast(EditorAction::invalid)) { + qWarning("Unexpected editor action %d", in); + return EditorAction::invalid; + } + + return static_cast(in); + } + + Model::Model(QObject* parent) + : QAbstractTableModel(parent) + {} + + QVariant Model::headerData(int section, Qt::Orientation orientation, int role) const + { + if (role != Qt::DisplayRole) { + return QVariant(); + } + + if (orientation != Qt::Orientation::Horizontal) { + return QVariant(); + } + + const auto column = toFileTransferListColumn(section); + return toQString(column); + } + + void Model::onFileUpdated(const ToxFile& file) + { + if (!shouldProcessFileKind(file.fileKind)) { + return; + } + + auto idxIt = idToRow.find(file.resumeFileId); + int rowIdx = 0; + + if (idxIt == idToRow.end()) { + if (files.size() >= std::numeric_limits::max()) { + // Bug waiting to happen, but also what can we do if qt just doesn't + // support this many items in a list + qWarning("Too many file transfers rendered, ignoring"); + return; + } + + auto insertedIdx = files.size(); + + emit rowsAboutToBeInserted(QModelIndex(), insertedIdx, insertedIdx, {}); + + files.push_back(file); + idToRow.insert(file.resumeFileId, insertedIdx); + + emit rowsInserted(QModelIndex(), insertedIdx, insertedIdx, {}); + } else { + rowIdx = idxIt.value(); + files[rowIdx] = file; + if (fileTransferFailed(file.status)) { + emit rowsAboutToBeRemoved(QModelIndex(), rowIdx, rowIdx, {}); + + for (auto it = idToRow.begin(); it != idToRow.end(); ++it) { + if (it.value() > rowIdx) { + it.value() -= 1; + } + } + idToRow.remove(file.resumeFileId); + files.erase(files.begin() + rowIdx); + + emit rowsRemoved(QModelIndex(), rowIdx, rowIdx, {}); + } + else { + emit dataChanged(index(rowIdx, 0), index(rowIdx, columnCount())); + } + } + + } + + int Model::rowCount(const QModelIndex& parent) const + { + return files.size(); + } + + int Model::columnCount(const QModelIndex& parent) const + { + return static_cast(Column::invalid); + } + + QVariant Model::data(const QModelIndex& index, int role) const + { + const auto row = index.row(); + if (row < 0 || static_cast(row) > files.size()) { + qWarning("Invalid file transfer row %d (files: %lu)", row, files.size()); + return QVariant(); + } + + if (role == Qt::UserRole) { + return files[row].filePath; + } + + if (role != Qt::DisplayRole) { + return QVariant(); + } + + const auto column = toFileTransferListColumn(index.column()); + + switch (column) + { + case Column::fileName: + return files[row].fileName; + case Column::contact: + { + auto f = FriendList::findFriend(FriendList::id2Key(files[row].friendId)); + if (f == nullptr) { + qWarning("Invalid friend for file transfer"); + return "Unknown"; + } + + return f->getDisplayedName(); + } + case Column::progress: + return files[row].progress.getProgress() * 100.0; + case Column::size: + return getHumanReadableSize(files[row].progress.getFileSize()); + case Column::speed: + return getHumanReadableSize(files[row].progress.getSpeed()) + "/s"; + case Column::status: + return fileStatusString(files[row]); + case Column::control: + return files[row].pauseStatus.localPaused(); + case Column::invalid: + break; + } + + return QVariant(); + } + + bool Model::setData(const QModelIndex &index, const QVariant &value, int role) + { + const auto column = toFileTransferListColumn(index.column()); + + if (column != Column::control) { + return false; + } + + if (!value.canConvert()) { + qWarning("Unexpected model data"); + return false; + } + + const auto action = toEditorAction(value.toInt()); + + switch (action) + { + case EditorAction::cancel: + emit cancel(files[index.row()]); + break; + case EditorAction::pause: + emit togglePause(files[index.row()]); + break; + case EditorAction::invalid: + return false; + } + + return true; + } + + Delegate::Delegate(QWidget* parent) + : QStyledItemDelegate(parent) + {} + + void Delegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const + { + const auto column = toFileTransferListColumn(index.column()); + switch (column) + { + case Column::progress: + { + int progress = index.data().toInt(); + + QStyleOptionProgressBar progressBarOption; + progressBarOption.rect = option.rect; + progressBarOption.minimum = 0; + progressBarOption.maximum = 100; + progressBarOption.progress = progress; + progressBarOption.text = QString::number(progress) + "%"; + progressBarOption.textVisible = true; + + QApplication::style()->drawControl(QStyle::CE_ProgressBar, + &progressBarOption, painter); + return; + } + case Column::control: + { + const auto data = index.data(); + if (!data.canConvert()) { + qWarning("Unexpected control type, not rendering controls"); + return; + } + const auto localPaused = data.toBool(); + QPixmap pausePixmap = localPaused + ? QPixmap(Style::getImagePath("fileTransferInstance/arrow_black.svg")) + : QPixmap(Style::getImagePath("fileTransferInstance/pause_dark.svg")); + QApplication::style()->drawItemPixmap(painter, pauseRect(option), Qt::AlignCenter, pausePixmap); + + QPixmap stopPixmap(Style::getImagePath("fileTransferInstance/no_dark.svg")); + QApplication::style()->drawItemPixmap(painter, stopRect(option), Qt::AlignCenter, stopPixmap); + return; + } + case Column::fileName: + case Column::contact: + case Column::size: + case Column::speed: + case Column::status: + case Column::invalid: + break; + } + + QStyledItemDelegate::paint(painter, option, index); + } + + bool Delegate::editorEvent(QEvent* event, QAbstractItemModel* model, + const QStyleOptionViewItem& option, const QModelIndex& index) + { + if (toFileTransferListColumn(index.column()) == Column::control) + { + if (event->type() == QEvent::MouseButtonPress) { + auto mouseEvent = reinterpret_cast(event); + const auto pos = mouseEvent->pos(); + const auto posRect = pauseRect(option); + const auto stRect = stopRect(option); + + if (posRect.contains(pos)) { + model->setData(index, static_cast(EditorAction::pause)); + } else if (stRect.contains(pos)) { + model->setData(index, static_cast(EditorAction::cancel)); + } + } + return true; + } + return false; + } + + + View::View(QAbstractItemModel* model, QWidget* parent) + : QTableView(parent) + { + setModel(model); + + // Resize to contents but stretch the file name to fill the full view + horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch); + // Visually tuned until it looked ok + horizontalHeader()->setMinimumSectionSize(75); + horizontalHeader()->setStretchLastSection(false); + verticalHeader()->hide(); + setShowGrid(false); + setSelectionBehavior(QAbstractItemView::SelectRows); + setSelectionMode(QAbstractItemView::SingleSelection); + setItemDelegate(new Delegate(this)); + } + + View::~View() = default; + +} // namespace FileTransferList + +FilesForm::FilesForm(CoreFile& coreFile) : QObject() - , doneIcon(Style::getImagePath("fileTransferWidget/fileDone.svg")) { head = new QWidget(); QFont bold; @@ -36,14 +430,34 @@ FilesForm::FilesForm() head->setLayout(&headLayout); headLayout.addWidget(&headLabel); - recvd = new QListWidget; - sent = new QListWidget; + recvdModel = new FileTransferList::Model(this); + sentModel = new FileTransferList::Model(this); + + auto pauseFile = [&coreFile] (ToxFile file) { + coreFile.pauseResumeFile(file.friendId, file.fileNum); + }; + + auto cancelFileRecv = [&coreFile] (ToxFile file) { + coreFile.cancelFileRecv(file.friendId, file.fileNum); + }; + + auto cancelFileSend = [&coreFile] (ToxFile file) { + coreFile.cancelFileSend(file.friendId, file.fileNum); + }; + + connect(recvdModel, &FileTransferList::Model::togglePause, pauseFile); + connect(recvdModel, &FileTransferList::Model::cancel, cancelFileRecv); + connect(sentModel, &FileTransferList::Model::togglePause, pauseFile); + connect(sentModel, &FileTransferList::Model::cancel, cancelFileSend); + + recvd = new FileTransferList::View(recvdModel); + sent = new FileTransferList::View(sentModel); main.addTab(recvd, QString()); main.addTab(sent, QString()); - connect(sent, &QListWidget::itemActivated, this, &FilesForm::onFileActivated); - connect(recvd, &QListWidget::itemActivated, this, &FilesForm::onFileActivated); + connect(sent, &QTableView::activated, this, &FilesForm::onSentFileActivated); + connect(recvd, &QTableView::activated, this, &FilesForm::onReceivedFileActivated); retranslateUi(); Translator::registerHandler(std::bind(&FilesForm::retranslateUi, this), this); @@ -75,28 +489,33 @@ void FilesForm::show(ContentLayout* contentLayout) head->show(); } -void FilesForm::onFileDownloadComplete(const QString& path) +void FilesForm::onFileUpdated(const ToxFile& inFile) { - QListWidgetItem* tmp = new QListWidgetItem(doneIcon, QFileInfo(path).fileName()); - tmp->setData(Qt::UserRole, path); - recvd->addItem(tmp); + if (!shouldProcessFileKind(inFile.fileKind)) { + return; + } + + if (inFile.direction == ToxFile::SENDING) { + sentModel->onFileUpdated(inFile); + } + else if (inFile.direction == ToxFile::RECEIVING) { + recvdModel->onFileUpdated(inFile); + } + else { + qWarning("Unexpected file direction"); + } } -void FilesForm::onFileUploadComplete(const QString& path) +void FilesForm::onSentFileActivated(const QModelIndex& index) { - QListWidgetItem* tmp = new QListWidgetItem(doneIcon, QFileInfo(path).fileName()); - tmp->setData(Qt::UserRole, path); - sent->addItem(tmp); + const auto& filePath = sentModel->data(index, Qt::UserRole).toString(); + Widget::confirmExecutableOpen(filePath); } -// sadly, the ToxFile struct in core only has the file name, not the file path... -// so currently, these don't work as intended (though for now, downloads might work -// whenever they're not saved anywhere custom, thanks to the hack) -// I could do some digging around, but for now I'm tired and others already -// might know it without me needing to dig, so... -void FilesForm::onFileActivated(QListWidgetItem* item) +void FilesForm::onReceivedFileActivated(const QModelIndex& index) { - Widget::confirmExecutableOpen(QFileInfo(item->data(Qt::UserRole).toString())); + const auto& filePath = recvdModel->data(index, Qt::UserRole).toString(); + Widget::confirmExecutableOpen(filePath); } void FilesForm::retranslateUi() diff --git a/src/widget/form/filesform.h b/src/widget/form/filesform.h index 2322ea140..b39c8035f 100644 --- a/src/widget/form/filesform.h +++ b/src/widget/form/filesform.h @@ -19,41 +19,121 @@ #pragma once +#include "src/core/toxfile.h" +#include "src/core/corefile.h" + #include #include +#include #include #include #include +#include +#include +#include class ContentLayout; -class QListWidget; +class QTableView; + +namespace FileTransferList +{ + + enum class Column : int { + // NOTE: Order defines order in UI + fileName, + contact, + progress, + size, + speed, + status, + control, + invalid + }; + + Column toFileTransferListColumn(int in); + QString toQString(Column column); + + enum class EditorAction : int { + pause, + cancel, + invalid, + }; + + EditorAction toEditorAction(int in); + + class Model : public QAbstractTableModel + { + Q_OBJECT + public: + Model(QObject* parent = nullptr); + ~Model() = default; + + void onFileUpdated(const ToxFile& file); + + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + int rowCount(const QModelIndex& parent = QModelIndex()) const override; + int columnCount(const QModelIndex& parent = QModelIndex()) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; + + signals: + void togglePause(ToxFile file); + void cancel(ToxFile file); + + private: + QHash idToRow; + std::vector files; + }; + + class Delegate : public QStyledItemDelegate + { + public: + Delegate(QWidget* parent = nullptr); + void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override; + + bool editorEvent(QEvent* event, QAbstractItemModel* model, const QStyleOptionViewItem& option, const QModelIndex& index) override; + }; + + class View : public QTableView + { + public: + View(QAbstractItemModel* model, QWidget* parent = nullptr); + ~View(); + + }; +} // namespace FileTransferList class FilesForm : public QObject { Q_OBJECT public: - FilesForm(); + FilesForm(CoreFile& coreFile); ~FilesForm(); bool isShown() const; void show(ContentLayout* contentLayout); public slots: - void onFileDownloadComplete(const QString& path); - void onFileUploadComplete(const QString& path); + void onFileUpdated(const ToxFile& file); private slots: - void onFileActivated(QListWidgetItem* item); + void onSentFileActivated(const QModelIndex& item); + void onReceivedFileActivated(const QModelIndex& item); private: + struct FileInfo + { + QListWidgetItem* item = nullptr; + ToxFile file; + }; + void retranslateUi(); -private: QWidget* head; - QIcon doneIcon; QLabel headLabel; QVBoxLayout headLayout; QTabWidget main; - QListWidget *sent, *recvd; + QTableView *sent, *recvd; + FileTransferList::Model *sentModel, *recvdModel; }; diff --git a/src/widget/widget.cpp b/src/widget/widget.cpp index 14c8d31ea..3767913a7 100644 --- a/src/widget/widget.cpp +++ b/src/widget/widget.cpp @@ -279,7 +279,8 @@ void Widget::init() Style::setThemeColor(settings.getThemeColor()); - filesForm = new FilesForm(); + CoreFile* coreFile = core->getCoreFile(); + filesForm = new FilesForm(*coreFile); addFriendForm = new AddFriendForm(core->getSelfId()); groupInviteForm = new GroupInviteForm; @@ -292,7 +293,6 @@ void Widget::init() updateCheck->checkForUpdate(); #endif - CoreFile* coreFile = core->getCoreFile(); profileInfo = new ProfileInfo(core, &profile); profileForm = new ProfileForm(profileInfo); @@ -307,8 +307,6 @@ void Widget::init() connect(&profile, &Profile::selfAvatarChanged, profileForm, &ProfileForm::onSelfAvatarLoaded); connect(coreFile, &CoreFile::fileReceiveRequested, this, &Widget::onFileReceiveRequested); - connect(coreFile, &CoreFile::fileDownloadFinished, filesForm, &FilesForm::onFileDownloadComplete); - connect(coreFile, &CoreFile::fileUploadFinished, filesForm, &FilesForm::onFileUploadComplete); connect(ui->addButton, &QPushButton::clicked, this, &Widget::onAddClicked); connect(ui->groupButton, &QPushButton::clicked, this, &Widget::onGroupClicked); connect(ui->transferButton, &QPushButton::clicked, this, &Widget::onTransferClicked); @@ -1136,6 +1134,8 @@ void Widget::dispatchFile(ToxFile file) const auto senderPk = (file.direction == ToxFile::SENDING) ? core->getSelfPublicKey() : pk; friendChatLogs[pk]->onFileUpdated(senderPk, file); + + filesForm->onFileUpdated(file); } void Widget::dispatchFileWithBool(ToxFile file, bool) diff --git a/test/widget/filesform_test.cpp b/test/widget/filesform_test.cpp new file mode 100644 index 000000000..d5bf85ea5 --- /dev/null +++ b/test/widget/filesform_test.cpp @@ -0,0 +1,282 @@ +/* + Copyright © 2021 by The qTox Project Contributors + + This file is part of qTox, a Qt-based graphical interface for Tox. + + qTox is libre software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + qTox is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with qTox. If not, see . +*/ + +#include "src/widget/form/filesform.h" +#include "src/friendlist.h" +#include "src/model/friend.h" + +#include +#include + +class TestFileTransferList : public QObject +{ + Q_OBJECT +private slots: + + void testFileTransferListConversion(); + void testEditorActionConversion(); + + void testFileName(); + // NOTE: Testing contact return requires a lookup in FriendList which goes + // down a large dependency chain that is not linked to this test + // void testContact(); + void testProgress(); + void testSize(); + void testSpeed(); + void testStatus(); + void testControl(); + void testAvatarIgnored(); + void testMultipleFiles(); + void testFileRemoval(); +}; + +using namespace FileTransferList; + +void TestFileTransferList::testFileTransferListConversion() +{ + Model model; + for (int i = 0; i < model.columnCount(); ++i) { + QVERIFY(toFileTransferListColumn(i) != Column::invalid); + } + QCOMPARE(toFileTransferListColumn(100), Column::invalid); +} + +void TestFileTransferList::testEditorActionConversion() +{ + QCOMPARE(toEditorAction(static_cast(EditorAction::pause)), EditorAction::pause); + QCOMPARE(toEditorAction(static_cast(EditorAction::cancel)), EditorAction::cancel); + QCOMPARE(toEditorAction(55), EditorAction::invalid); +} + +void TestFileTransferList::testFileName() +{ + Model model; + + ToxFile file; + file.fileKind = TOX_FILE_KIND_DATA; + file.fileName = "Test"; + model.onFileUpdated(file); + + const auto idx = model.index(0, static_cast(Column::fileName)); + const auto fileName = idx.data(); + + QCOMPARE(fileName.toString(), QString("Test")); +} + +void TestFileTransferList::testProgress() +{ + Model model; + + ToxFile file(0, 0, "", "", 1000, ToxFile::FileDirection::SENDING); + file.progress.addSample(100, QTime(1, 0, 0)); + model.onFileUpdated(file); + + const auto idx = model.index(0, static_cast(Column::progress)); + const auto progress = idx.data(); + + // Progress should be in percent units, 100/1000 == 10 + QCOMPARE(progress.toFloat(), 10.0f); +} + +void TestFileTransferList::testSize() +{ + Model model; + + ToxFile file(0, 0, "", "", 1000, ToxFile::FileDirection::SENDING); + model.onFileUpdated(file); + + const auto idx = model.index(0, static_cast(Column::size)); + auto size = idx.data(); + + // Size should be a human readable string + QCOMPARE(size.toString(), QString("1000B")); + + // 1GB + a little to avoid floating point inaccuracy + file = ToxFile(0, 0, "", "", 1024 * 1024 * 1024 + 2, ToxFile::FileDirection::SENDING); + model.onFileUpdated(file); + size = idx.data(); + QCOMPARE(size.toString(), QString("1.00GiB")); +} + +void TestFileTransferList::testSpeed() +{ + Model model; + + ToxFile file(0, 0, "", "", 1024 * 1024, ToxFile::FileDirection::SENDING); + file.progress.addSample(100 * 1024, QTime(1, 0, 0)); + file.progress.addSample(200 * 1024, QTime(1, 0, 1)); + model.onFileUpdated(file); + + const auto idx = model.index(0, static_cast(Column::speed)); + const auto speed = idx.data(); + + // Speed should be a human readable string + QCOMPARE(speed.toString(), QString("100KiB/s")); +} + +void TestFileTransferList::testStatus() +{ + Model model; + + ToxFile file(0, 0, "", "", 1024 * 1024, ToxFile::FileDirection::SENDING); + file.status = ToxFile::TRANSMITTING; + model.onFileUpdated(file); + + const auto idx = model.index(0, static_cast(Column::status)); + auto status = idx.data(); + + QCOMPARE(status.toString(), QString("Transmitting")); + + file.status = ToxFile::PAUSED; + file.pauseStatus.remotePause(); + model.onFileUpdated(file); + status = idx.data(); + + QCOMPARE(status.toString(), QString("Remote paused")); + + file.status = ToxFile::PAUSED; + file.pauseStatus.localPause(); + file.pauseStatus.remoteResume(); + model.onFileUpdated(file); + status = idx.data(); + + QCOMPARE(status.toString(), QString("Paused")); +} + +void TestFileTransferList::testControl() +{ + Model model; + bool cancelCalled = false; + bool pauseCalled = false; + + QObject::connect(&model, &Model::cancel, [&] (ToxFile file) { + cancelCalled = true; + }); + + QObject::connect(&model, &Model::togglePause, [&] (ToxFile file) { + pauseCalled = true; + }); + + ToxFile file(0, 0, "", "", 1024 * 1024, ToxFile::FileDirection::SENDING); + file.status = ToxFile::TRANSMITTING; + model.onFileUpdated(file); + + const auto idx = model.index(0, static_cast(Column::control)); + model.setData(idx, static_cast(EditorAction::pause)); + + QVERIFY(pauseCalled); + QVERIFY(!cancelCalled); + + pauseCalled = false; + model.setData(idx, static_cast(EditorAction::cancel)); + QVERIFY(!pauseCalled); + QVERIFY(cancelCalled); + + file.status = ToxFile::TRANSMITTING; + model.onFileUpdated(file); + // True if paused + QCOMPARE(idx.data().toBool(), false); + + file.status = ToxFile::PAUSED; + file.pauseStatus.localPause(); + model.onFileUpdated(file); + // True if _local_ paused + QCOMPARE(idx.data().toBool(), true); +} + +void TestFileTransferList::testAvatarIgnored() +{ + Model model; + + ToxFile file; + file.fileKind = TOX_FILE_KIND_AVATAR; + model.onFileUpdated(file); + + QCOMPARE(model.rowCount(), 0); +} + +void TestFileTransferList::testMultipleFiles() +{ + Model model; + + ToxFile file; + file.resumeFileId = QByteArray(); + file.fileKind = TOX_FILE_KIND_DATA; + file.status = ToxFile::TRANSMITTING; + file.fileName = "a"; + model.onFileUpdated(file); + + // File map keys off resume file ID + file.resumeFileId = QByteArray("asdfasdf"); + file.fileName = "b"; + model.onFileUpdated(file); + + QCOMPARE(model.rowCount(), 2); + + auto idx = model.index(0, static_cast(Column::fileName)); + QCOMPARE(idx.data().toString(), QString("a")); + + idx = model.index(1, static_cast(Column::fileName)); + QCOMPARE(idx.data().toString(), QString("b")); + + // File name should be updated instead of inserting a new file since the + // resume file ID is the same + file.fileName = "c"; + model.onFileUpdated(file); + QCOMPARE(model.rowCount(), 2); + QCOMPARE(idx.data().toString(), QString("c")); +} + +void TestFileTransferList::testFileRemoval() +{ + // Model should keep files in the list if they are finished, but not if they + // were broken or canceled + + Model model; + + ToxFile file; + file.fileKind = TOX_FILE_KIND_DATA; + file.status = ToxFile::TRANSMITTING; + model.onFileUpdated(file); + + QCOMPARE(model.rowCount(), 1); + + file.status = ToxFile::BROKEN; + model.onFileUpdated(file); + QCOMPARE(model.rowCount(), 0); + + file.status = ToxFile::TRANSMITTING; + model.onFileUpdated(file); + QCOMPARE(model.rowCount(), 1); + + file.status = ToxFile::CANCELED; + model.onFileUpdated(file); + QCOMPARE(model.rowCount(), 0); + + file.status = ToxFile::TRANSMITTING; + model.onFileUpdated(file); + QCOMPARE(model.rowCount(), 1); + + file.status = ToxFile::FINISHED; + model.onFileUpdated(file); + QCOMPARE(model.rowCount(), 1); +} + +QTEST_GUILESS_MAIN(TestFileTransferList) +#include "filesform_test.moc" diff --git a/themes/dark/fileTransferInstance/arrow_black.svg b/themes/dark/fileTransferInstance/arrow_black.svg new file mode 100644 index 000000000..a10b55c94 --- /dev/null +++ b/themes/dark/fileTransferInstance/arrow_black.svg @@ -0,0 +1,44 @@ + + + +image/svg+xml diff --git a/themes/dark/fileTransferInstance/no_dark.svg b/themes/dark/fileTransferInstance/no_dark.svg new file mode 100644 index 000000000..dabe9996b --- /dev/null +++ b/themes/dark/fileTransferInstance/no_dark.svg @@ -0,0 +1,47 @@ + + + +image/svg+xml diff --git a/themes/dark/fileTransferInstance/pause_dark.svg b/themes/dark/fileTransferInstance/pause_dark.svg new file mode 100644 index 000000000..a5a87edfa --- /dev/null +++ b/themes/dark/fileTransferInstance/pause_dark.svg @@ -0,0 +1,58 @@ + + + +image/svg+xml diff --git a/themes/default/fileTransferInstance/arrow_black.svg b/themes/default/fileTransferInstance/arrow_black.svg new file mode 100644 index 000000000..a10b55c94 --- /dev/null +++ b/themes/default/fileTransferInstance/arrow_black.svg @@ -0,0 +1,44 @@ + + + +image/svg+xml diff --git a/themes/default/fileTransferInstance/no_dark.svg b/themes/default/fileTransferInstance/no_dark.svg new file mode 100644 index 000000000..dabe9996b --- /dev/null +++ b/themes/default/fileTransferInstance/no_dark.svg @@ -0,0 +1,47 @@ + + + +image/svg+xml diff --git a/themes/default/fileTransferInstance/pause_dark.svg b/themes/default/fileTransferInstance/pause_dark.svg new file mode 100644 index 000000000..a5a87edfa --- /dev/null +++ b/themes/default/fileTransferInstance/pause_dark.svg @@ -0,0 +1,58 @@ + + + +image/svg+xml diff --git a/util/CMakeLists.txt b/util/CMakeLists.txt index 945b8b438..5952aca29 100644 --- a/util/CMakeLists.txt +++ b/util/CMakeLists.txt @@ -14,13 +14,15 @@ # You should have received a copy of the GNU General Public License # along with qTox. If not, see -set(HEADER_LIST + +add_library(util_library STATIC "include/util/compatiblerecursivemutex.h" "include/util/interface.h" - "include/util/strongtype.h") - -add_library(util_library STATIC ${HEADER_LIST}) + "include/util/strongtype.h" + "include/util/display.h" + "src/display.cpp") # We need this directory, and users of our library will need it too target_include_directories(util_library PUBLIC include/) +target_link_libraries(util_library PRIVATE Qt5::Core) diff --git a/util/include/util/display.h b/util/include/util/display.h new file mode 100644 index 000000000..8980d5fb9 --- /dev/null +++ b/util/include/util/display.h @@ -0,0 +1,26 @@ +/* + Copyright © 2021 by The qTox Project Contributors + + This file is part of qTox, a Qt-based graphical interface for Tox. + + qTox is libre software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + qTox is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with qTox. If not, see . +*/ + +#pragma once + +#include + +#include + +QString getHumanReadableSize(uint64_t sizeBytes); diff --git a/util/src/display.cpp b/util/src/display.cpp new file mode 100644 index 000000000..920e1f5f1 --- /dev/null +++ b/util/src/display.cpp @@ -0,0 +1,34 @@ +/* + Copyright © 2021 by The qTox Project Contributors + + This file is part of qTox, a Qt-based graphical interface for Tox. + + qTox is libre software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + qTox is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with qTox. If not, see . +*/ + +#include "util/display.h" + +#include + +QString getHumanReadableSize(uint64_t size) +{ + static const char* suffix[] = {"B", "KiB", "MiB", "GiB", "TiB"}; + int exp = 0; + + if (size > 0) { + exp = std::min(static_cast(log(size) / log(1024)), static_cast(sizeof(suffix) / sizeof(suffix[0]) - 1)); + } + + return QString().setNum(size / pow(1024, exp), 'f', exp > 1 ? 2 : 0).append(suffix[exp]); +}