603 lines
14 KiB
C++
603 lines
14 KiB
C++
// This file Copyright © 2009-2023 Mnemosyne LLC.
|
|
// It may be used under GPLv2 (SPDX: GPL-2.0-only), GPLv3 (SPDX: GPL-3.0-only),
|
|
// or any future license endorsed by Mnemosyne LLC.
|
|
// License text can be found in the licenses/ folder.
|
|
|
|
#include <algorithm>
|
|
#include <cassert>
|
|
#include <memory>
|
|
|
|
#include <libtransmission/transmission.h> // priorities
|
|
|
|
#include "FileTreeItem.h"
|
|
#include "FileTreeModel.h"
|
|
|
|
namespace
|
|
{
|
|
|
|
class PathIteratorBase
|
|
{
|
|
protected:
|
|
PathIteratorBase(QString const& path, int slash_index)
|
|
: path_(path)
|
|
, slash_index_(slash_index)
|
|
{
|
|
token_.reserve(path.size() / 2);
|
|
}
|
|
|
|
QString const& path_;
|
|
QString token_;
|
|
int slash_index_;
|
|
|
|
static QChar const SlashChar;
|
|
};
|
|
|
|
QChar const PathIteratorBase::SlashChar = QLatin1Char('/');
|
|
|
|
class ForwardPathIterator : public PathIteratorBase
|
|
{
|
|
public:
|
|
explicit ForwardPathIterator(QString const& path)
|
|
: PathIteratorBase(path, path.size() - 1)
|
|
{
|
|
}
|
|
|
|
[[nodiscard]] bool hasNext() const
|
|
{
|
|
return slash_index_ > -1;
|
|
}
|
|
|
|
QString const& next()
|
|
{
|
|
int const new_slash_index = path_.lastIndexOf(SlashChar, slash_index_);
|
|
token_.truncate(0);
|
|
token_.append(&path_.data()[new_slash_index + 1], slash_index_ - new_slash_index);
|
|
slash_index_ = new_slash_index - 1;
|
|
return token_;
|
|
}
|
|
};
|
|
|
|
class BackwardPathIterator : public PathIteratorBase
|
|
{
|
|
public:
|
|
explicit BackwardPathIterator(QString const& path)
|
|
: PathIteratorBase(path, 0)
|
|
{
|
|
}
|
|
|
|
[[nodiscard]] bool hasNext() const
|
|
{
|
|
return slash_index_ < path_.size();
|
|
}
|
|
|
|
QString const& next()
|
|
{
|
|
int new_slash_index = path_.indexOf(SlashChar, slash_index_);
|
|
|
|
if (new_slash_index == -1)
|
|
{
|
|
new_slash_index = path_.size();
|
|
}
|
|
|
|
token_.truncate(0);
|
|
token_.append(&path_.data()[slash_index_], new_slash_index - slash_index_);
|
|
slash_index_ = new_slash_index + 1;
|
|
return token_;
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
FileTreeModel::FileTreeModel(QObject* parent, bool is_editable)
|
|
: QAbstractItemModel{ parent }
|
|
, root_item_{ std::make_unique<FileTreeItem>() }
|
|
, is_editable_{ is_editable }
|
|
{
|
|
}
|
|
|
|
FileTreeModel::~FileTreeModel()
|
|
{
|
|
clear();
|
|
}
|
|
|
|
void FileTreeModel::setEditable(bool is_editable)
|
|
{
|
|
is_editable_ = is_editable;
|
|
}
|
|
|
|
FileTreeItem* FileTreeModel::itemFromIndex(QModelIndex const& index) const
|
|
{
|
|
if (!index.isValid())
|
|
{
|
|
return nullptr;
|
|
}
|
|
|
|
assert(index.model() == this);
|
|
return static_cast<FileTreeItem*>(index.internalPointer());
|
|
}
|
|
|
|
QModelIndexList FileTreeModel::getOrphanIndices(QModelIndexList const& indices) const
|
|
{
|
|
QModelIndexList orphan_indices = indices;
|
|
|
|
std::sort(orphan_indices.begin(), orphan_indices.end());
|
|
|
|
for (QMutableListIterator<QModelIndex> it(orphan_indices); it.hasNext();)
|
|
{
|
|
QModelIndex walk = it.next();
|
|
|
|
for (;;)
|
|
{
|
|
walk = parent(walk, walk.column());
|
|
|
|
if (!walk.isValid())
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (std::binary_search(orphan_indices.begin(), orphan_indices.end(), walk))
|
|
{
|
|
it.remove();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return orphan_indices;
|
|
}
|
|
|
|
QVariant FileTreeModel::data(QModelIndex const& index, int role) const
|
|
{
|
|
if (index.isValid())
|
|
{
|
|
return itemFromIndex(index)->data(index.column(), role);
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
Qt::ItemFlags FileTreeModel::flags(QModelIndex const& index) const
|
|
{
|
|
int i(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
|
|
|
|
if (is_editable_ && index.column() == COL_NAME)
|
|
{
|
|
i |= Qt::ItemIsEditable;
|
|
}
|
|
|
|
if (index.column() == COL_WANTED)
|
|
{
|
|
i |= Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate;
|
|
}
|
|
|
|
return { i };
|
|
}
|
|
|
|
bool FileTreeModel::setData(QModelIndex const& index, QVariant const& value, int role)
|
|
{
|
|
if (role == Qt::EditRole)
|
|
{
|
|
FileTreeItem const* item = itemFromIndex(index);
|
|
|
|
emit pathEdited(item->path(), value.toString());
|
|
}
|
|
|
|
return false; // don't update the view until the session confirms the change
|
|
}
|
|
|
|
QVariant FileTreeModel::headerData(int column, Qt::Orientation orientation, int role) const
|
|
{
|
|
if (orientation == Qt::Horizontal && role == Qt::DisplayRole)
|
|
{
|
|
switch (column)
|
|
{
|
|
case COL_NAME:
|
|
return tr("File");
|
|
|
|
case COL_SIZE:
|
|
return tr("Size");
|
|
|
|
case COL_PROGRESS:
|
|
return tr("Progress");
|
|
|
|
case COL_WANTED:
|
|
return tr("Download");
|
|
|
|
case COL_PRIORITY:
|
|
return tr("Priority");
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
QModelIndex FileTreeModel::index(int row, int column, QModelIndex const& parent) const
|
|
{
|
|
if (hasIndex(row, column, parent))
|
|
{
|
|
auto* const parent_item = parent.isValid() ? itemFromIndex(parent) : root_item_.get();
|
|
|
|
if (auto* const child_item = parent_item->child(row); child_item != nullptr)
|
|
{
|
|
return createIndex(row, column, child_item);
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
QModelIndex FileTreeModel::parent(QModelIndex const& child) const
|
|
{
|
|
return parent(child, 0); // QAbstractItemModel::parent() wants col 0
|
|
}
|
|
|
|
QModelIndex FileTreeModel::parent(QModelIndex const& child, int column) const
|
|
{
|
|
QModelIndex parent;
|
|
|
|
if (child.isValid())
|
|
{
|
|
parent = indexOf(itemFromIndex(child)->parent(), column);
|
|
}
|
|
|
|
return parent;
|
|
}
|
|
|
|
int FileTreeModel::rowCount(QModelIndex const& parent) const
|
|
{
|
|
FileTreeItem const* parent_item = parent.isValid() ? itemFromIndex(parent) : root_item_.get();
|
|
|
|
return parent_item->childCount();
|
|
}
|
|
|
|
int FileTreeModel::columnCount(QModelIndex const& parent) const
|
|
{
|
|
Q_UNUSED(parent)
|
|
|
|
return NUM_COLUMNS;
|
|
}
|
|
|
|
QModelIndex FileTreeModel::indexOf(FileTreeItem* item, int column) const
|
|
{
|
|
if (item == nullptr || item == root_item_.get())
|
|
{
|
|
return {};
|
|
}
|
|
|
|
return createIndex(item->row(), column, item);
|
|
}
|
|
|
|
void FileTreeModel::clearSubtree(QModelIndex const& top)
|
|
{
|
|
size_t i = rowCount(top);
|
|
|
|
while (i > 0)
|
|
{
|
|
clearSubtree(index(--i, 0, top));
|
|
}
|
|
|
|
FileTreeItem* const item = itemFromIndex(top);
|
|
|
|
if (item == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (auto const idx = item->fileIndex(); idx != -1)
|
|
{
|
|
if (auto const iter = index_cache_.find(idx); iter != std::end(index_cache_))
|
|
{
|
|
index_cache_.erase(iter);
|
|
}
|
|
}
|
|
|
|
delete item;
|
|
}
|
|
|
|
void FileTreeModel::clear()
|
|
{
|
|
beginResetModel();
|
|
clearSubtree(QModelIndex());
|
|
root_item_ = std::make_unique<FileTreeItem>();
|
|
endResetModel();
|
|
|
|
assert(std::empty(index_cache_));
|
|
}
|
|
|
|
FileTreeItem* FileTreeModel::findItemForFileIndex(int file_index) const
|
|
{
|
|
auto iter = index_cache_.find(file_index);
|
|
return iter == std::end(index_cache_) ? nullptr : iter->second;
|
|
}
|
|
|
|
void FileTreeModel::addFile(
|
|
int file_index,
|
|
QString const& filename,
|
|
bool wanted,
|
|
int priority,
|
|
uint64_t total_size,
|
|
uint64_t have,
|
|
bool update_fields)
|
|
{
|
|
auto* item = findItemForFileIndex(file_index);
|
|
|
|
if (item != nullptr) // this file is already in the tree, we've added this
|
|
{
|
|
QModelIndex index_with_changed_parents;
|
|
ForwardPathIterator filename_it(filename);
|
|
|
|
while (filename_it.hasNext())
|
|
{
|
|
auto const& token = filename_it.next();
|
|
auto const& [first_col, last_col] = item->update(token, wanted, priority, have, update_fields);
|
|
|
|
if (first_col >= 0)
|
|
{
|
|
emit dataChanged(indexOf(item, first_col), indexOf(item, last_col));
|
|
|
|
if (!index_with_changed_parents.isValid() && first_col <= COL_PRIORITY && last_col >= COL_SIZE)
|
|
{
|
|
index_with_changed_parents = indexOf(item, 0);
|
|
}
|
|
}
|
|
|
|
item = item->parent();
|
|
}
|
|
|
|
assert(item == root_item_.get());
|
|
|
|
if (index_with_changed_parents.isValid())
|
|
{
|
|
emitParentsChanged(index_with_changed_parents, COL_SIZE, COL_PRIORITY);
|
|
}
|
|
}
|
|
else // we haven't build the FileTreeItems for these tokens yet
|
|
{
|
|
bool added = false;
|
|
|
|
item = root_item_.get();
|
|
BackwardPathIterator filename_it(filename);
|
|
|
|
while (filename_it.hasNext())
|
|
{
|
|
QString const& token = filename_it.next();
|
|
FileTreeItem* child(item->child(token));
|
|
|
|
if (child == nullptr)
|
|
{
|
|
added = true;
|
|
QModelIndex const parent_index(indexOf(item, 0));
|
|
int const n(item->childCount());
|
|
|
|
beginInsertRows(parent_index, n, n);
|
|
|
|
if (!filename_it.hasNext())
|
|
{
|
|
child = new FileTreeItem(token, file_index, total_size);
|
|
}
|
|
else
|
|
{
|
|
child = new FileTreeItem(token);
|
|
}
|
|
|
|
item->appendChild(child);
|
|
endInsertRows();
|
|
}
|
|
|
|
item = child;
|
|
}
|
|
|
|
if (item != root_item_.get())
|
|
{
|
|
assert(item->fileIndex() == file_index);
|
|
assert(item->totalSize() == total_size);
|
|
|
|
index_cache_[file_index] = item;
|
|
|
|
auto const [first_col, last_col] = item->update(item->name(), wanted, priority, have, added || update_fields);
|
|
|
|
if (first_col >= 0)
|
|
{
|
|
emit dataChanged(indexOf(item, first_col), indexOf(item, last_col));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void FileTreeModel::emitParentsChanged(
|
|
QModelIndex const& index,
|
|
int first_column,
|
|
int last_column,
|
|
QSet<QModelIndex>* visited_parent_indices)
|
|
{
|
|
assert(first_column <= last_column);
|
|
|
|
QModelIndex walk = index;
|
|
|
|
for (;;)
|
|
{
|
|
walk = parent(walk, first_column);
|
|
|
|
if (!walk.isValid())
|
|
{
|
|
break;
|
|
}
|
|
|
|
if (visited_parent_indices != nullptr)
|
|
{
|
|
if (visited_parent_indices->contains(walk))
|
|
{
|
|
break;
|
|
}
|
|
|
|
visited_parent_indices->insert(walk);
|
|
}
|
|
|
|
emit dataChanged(walk, walk.sibling(walk.row(), last_column));
|
|
}
|
|
}
|
|
|
|
void FileTreeModel::emitSubtreeChanged(QModelIndex const& idx, int first_column, int last_column)
|
|
{
|
|
assert(first_column <= last_column);
|
|
|
|
int const child_count = rowCount(idx);
|
|
|
|
if (child_count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// tell everyone that this item changed
|
|
emit dataChanged(index(0, first_column, idx), index(child_count - 1, last_column, idx));
|
|
|
|
// walk the subitems
|
|
for (int i = 0; i < child_count; ++i)
|
|
{
|
|
emitSubtreeChanged(index(i, 0, idx), first_column, last_column);
|
|
}
|
|
}
|
|
|
|
void FileTreeModel::twiddleWanted(QModelIndexList const& indices)
|
|
{
|
|
QMap<bool, QModelIndexList> wanted_indices;
|
|
|
|
for (QModelIndex const& i : getOrphanIndices(indices))
|
|
{
|
|
FileTreeItem const* const item = itemFromIndex(i);
|
|
wanted_indices[item->isSubtreeWanted() != Qt::Checked] << i;
|
|
}
|
|
|
|
for (int i = 0; i <= 1; ++i)
|
|
{
|
|
if (wanted_indices.contains(i))
|
|
{
|
|
setWanted(wanted_indices[i], i != 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
void FileTreeModel::twiddlePriority(QModelIndexList const& indices)
|
|
{
|
|
QMap<int, QModelIndexList> priority_indices;
|
|
|
|
for (QModelIndex const& i : getOrphanIndices(indices))
|
|
{
|
|
FileTreeItem const* const item = itemFromIndex(i);
|
|
int priority = item->priority();
|
|
|
|
// ... -> normal -> high -> low -> normal -> ...; mixed -> normal
|
|
if (priority == FileTreeItem::Normal)
|
|
{
|
|
priority = TR_PRI_HIGH;
|
|
}
|
|
else if (priority == FileTreeItem::High)
|
|
{
|
|
priority = TR_PRI_LOW;
|
|
}
|
|
else
|
|
{
|
|
priority = TR_PRI_NORMAL;
|
|
}
|
|
|
|
priority_indices[priority] << i;
|
|
}
|
|
|
|
for (int i = TR_PRI_LOW; i <= TR_PRI_HIGH; ++i)
|
|
{
|
|
if (priority_indices.contains(i))
|
|
{
|
|
setPriority(priority_indices[i], i);
|
|
}
|
|
}
|
|
}
|
|
|
|
void FileTreeModel::setWanted(QModelIndexList const& indices, bool wanted)
|
|
{
|
|
if (indices.isEmpty())
|
|
{
|
|
return;
|
|
}
|
|
|
|
QModelIndexList const orphan_indices = getOrphanIndices(indices);
|
|
|
|
QSet<int> file_ids;
|
|
|
|
for (QModelIndex const& i : orphan_indices)
|
|
{
|
|
FileTreeItem* const item = itemFromIndex(i);
|
|
item->setSubtreeWanted(wanted, file_ids);
|
|
|
|
emit dataChanged(i, i);
|
|
emitSubtreeChanged(i, COL_WANTED, COL_WANTED);
|
|
}
|
|
|
|
// emit parent changes separately to avoid multiple updates for same items
|
|
QSet<QModelIndex> parent_indices;
|
|
|
|
for (QModelIndex const& i : orphan_indices)
|
|
{
|
|
emitParentsChanged(i, COL_SIZE, COL_WANTED, &parent_indices);
|
|
}
|
|
|
|
if (!file_ids.isEmpty())
|
|
{
|
|
emit wantedChanged(file_ids, wanted);
|
|
}
|
|
}
|
|
|
|
void FileTreeModel::setPriority(QModelIndexList const& indices, int priority)
|
|
{
|
|
if (indices.isEmpty())
|
|
{
|
|
return;
|
|
}
|
|
|
|
QModelIndexList const orphan_indices = getOrphanIndices(indices);
|
|
|
|
QSet<int> file_ids;
|
|
|
|
for (QModelIndex const& i : orphan_indices)
|
|
{
|
|
FileTreeItem* const item = itemFromIndex(i);
|
|
item->setSubtreePriority(priority, file_ids);
|
|
|
|
emit dataChanged(i, i);
|
|
emitSubtreeChanged(i, COL_PRIORITY, COL_PRIORITY);
|
|
}
|
|
|
|
// emit parent changes separately to avoid multiple updates for same items
|
|
QSet<QModelIndex> parent_indices;
|
|
|
|
for (QModelIndex const& i : orphan_indices)
|
|
{
|
|
emitParentsChanged(i, COL_PRIORITY, COL_PRIORITY, &parent_indices);
|
|
}
|
|
|
|
if (!file_ids.isEmpty())
|
|
{
|
|
emit priorityChanged(file_ids, priority);
|
|
}
|
|
}
|
|
|
|
bool FileTreeModel::openFile(QModelIndex const& index)
|
|
{
|
|
if (!index.isValid())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
FileTreeItem const* const item = itemFromIndex(index);
|
|
|
|
if (item->fileIndex() < 0 || !item->isComplete())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
emit openRequested(item->path());
|
|
return true;
|
|
}
|