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]); +}