diff --git a/model/foldermodel.cpp b/model/foldermodel.cpp index e2c1dd0..793b29e 100644 --- a/model/foldermodel.cpp +++ b/model/foldermodel.cpp @@ -946,6 +946,25 @@ void FolderModel::unpinSelection() m_pinnedSelection = QItemSelection(); } +// The cell a new folder or file should land on, in view coordinates. Consumed +// by the next newFolder()/newTextFile(); (-1, -1) means "wherever it fits". +void FolderModel::setNewDocumentPosition(int x, int y) +{ + m_newDocumentPos = QPoint(x, y); +} + +// Hands the pending cell over to the drop position map, which places the file +// on it as soon as the lister reports it. +void FolderModel::rememberNewDocumentPosition(const QString &name) +{ + if (m_newDocumentPos.x() < 0 || m_newDocumentPos.y() < 0) + return; + + m_dropTargetPositions.insert(name, m_newDocumentPos); + m_dropTargetPositionsCleanup->start(); + m_newDocumentPos = QPoint(-1, -1); +} + void FolderModel::newFolder() { QString rootPath = rootItem().url().toString(); @@ -964,6 +983,8 @@ void FolderModel::newFolder() m_newDocumentUrl = QUrl(rootItem().url().toString() + "/" + newName); + rememberNewDocumentPosition(newName); + auto job = KIO::mkdir(QUrl(rootItem().url().toString() + "/" + newName)); job->start(); } @@ -986,6 +1007,8 @@ void FolderModel::newTextFile() m_newDocumentUrl = QUrl(rootItem().url().toString() + "/" + newName); + rememberNewDocumentPosition(newName); + QFile file(m_newDocumentUrl.toLocalFile()); if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { QTextStream stream(&file); @@ -1019,12 +1042,13 @@ void FolderModel::copy() void FolderModel::paste() { + // The clipboard has no mime data at all when nothing owns it. const QMimeData *mimeData = QApplication::clipboard()->mimeData(); bool enable = false; // Update paste action if (QAction *paste = m_actionCollection.action(QStringLiteral("paste"))) { - QList urls = KUrlMimeData::urlsFromMimeData(mimeData); + QList urls = mimeData ? KUrlMimeData::urlsFromMimeData(mimeData) : QList(); const QString ¤tUrl = rootItem().url().toLocalFile(); if (!urls.isEmpty()) { @@ -1394,6 +1418,20 @@ void FolderModel::addItemDragImage(int row, int x, int y, int width, int height, m_dragImages.insert(row, dragImage); } +// Moves an already grabbed snapshot to where the icon sits now. Returns false +// when there is nothing stored for the row, so the caller can grab one. +bool FolderModel::updateItemDragRect(int row, int x, int y, int width, int height) +{ + DragImage *image = m_dragImages.value(row); + + if (!image) + return false; + + image->rect = QRect(x, y, width, height); + + return true; +} + void FolderModel::clearDragImages() { qDeleteAll(m_dragImages); @@ -1448,7 +1486,6 @@ void FolderModel::drop(QQuickItem *target, QObject *dropEvent, int row) const int x = dropEvent->property("x").toInt(); const int y = dropEvent->property("y").toInt(); - const QPoint dropPos = {x, y}; if (m_dragInProgress && row == -1) { if (mimeData->urls().isEmpty()) @@ -1456,10 +1493,9 @@ void FolderModel::drop(QQuickItem *target, QObject *dropEvent, int row) setSortMode(-1); - for (const auto &url : mimeData->urls()) { - m_dropTargetPositions.insert(url.fileName(), dropPos); - } - + // The files are already here -- no rows will be inserted for them, so + // recording drop positions would only leave entries behind that move + // some later file of the same name to a stale place. emit move(x, y, mimeData->urls()); return; @@ -1843,7 +1879,10 @@ void FolderModel::onRowsInserted(const QModelIndex &parent, int first, int last) if (it != m_dropTargetPositions.end()) { const auto pos = it.value(); m_dropTargetPositions.erase(it); - Q_EMIT move(pos.x(), pos.y(), {url}); + // Queued: the views are still inside the insertion transaction, and + // repositioning the row from within it would reenter their models. + QMetaObject::invokeMethod( + this, [this, pos, url] { Q_EMIT move(pos.x(), pos.y(), {url}); }, Qt::QueuedConnection); } if (url == m_newDocumentUrl) { @@ -2204,7 +2243,7 @@ void FolderModel::updateActions() bool enable = false; const QMimeData *mimeData = QApplication::clipboard()->mimeData(); - QList urls = KUrlMimeData::urlsFromMimeData(mimeData); + QList urls = mimeData ? KUrlMimeData::urlsFromMimeData(mimeData) : QList(); if (!urls.isEmpty()) { if (!rootItem().isNull()) { @@ -2266,6 +2305,20 @@ void FolderModel::addDragImage(QDrag *drag, int x, int y) QRegion region; + // A row that is not selected any more has a stale image and stale geometry; + // compositing it would drag the wrong picture, or place it far off-cursor. + for (auto it = m_dragImages.begin(); it != m_dragImages.end();) { + if (!isSelected(it.key())) { + delete it.value(); + it = m_dragImages.erase(it); + } else { + ++it; + } + } + + if (m_dragImages.isEmpty()) + return; + foreach (DragImage *image, m_dragImages) { image->blank = isBlank(image->row); image->rect.translate(-m_dragHotSpotScrollOffset.x(), -m_dragHotSpotScrollOffset.y()); diff --git a/model/foldermodel.h b/model/foldermodel.h index 28b3423..580f578 100644 --- a/model/foldermodel.h +++ b/model/foldermodel.h @@ -196,6 +196,7 @@ public: Q_INVOKABLE void pinSelection(); Q_INVOKABLE void unpinSelection(); + Q_INVOKABLE void setNewDocumentPosition(int x, int y); Q_INVOKABLE void newFolder(); Q_INVOKABLE void newTextFile(); Q_INVOKABLE void rename(int row, const QString &name); @@ -210,6 +211,7 @@ public: Q_INVOKABLE void keyDeletePress(); Q_INVOKABLE void setDragHotSpotScrollOffset(int x, int y); + Q_INVOKABLE bool updateItemDragRect(int row, int x, int y, int width, int height); Q_INVOKABLE void addItemDragImage(int row, int x, int y, int width, int height, const QVariant &image); Q_INVOKABLE void clearDragImages(); Q_INVOKABLE void dragSelected(int x, int y); @@ -294,6 +296,7 @@ private slots: const QString &outputPath); private: + void rememberNewDocumentPosition(const QString &name); void invalidateIfComplete(); void invalidateFilterIfComplete(); void createActions(); @@ -316,6 +319,7 @@ private: QItemSelection m_pinnedSelection; QString m_url; QUrl m_newDocumentUrl; + QPoint m_newDocumentPos{-1, -1}; QList m_needSelectUrls; Status m_status; diff --git a/model/positioner.cpp b/model/positioner.cpp index 441bcd9..b56426d 100644 --- a/model/positioner.cpp +++ b/model/positioner.cpp @@ -24,12 +24,14 @@ #include #include +#include Positioner::Positioner(QObject *parent) : QAbstractItemModel(parent) , m_enabled(false) , m_folderModel(nullptr) , m_perStripe(0) + , m_stripes(0) , m_ignoreNextTransaction(false) , m_deferApplyPositions(false) , m_updatePositionsTimer(new QTimer(this)) @@ -117,6 +119,40 @@ void Positioner::setPerStripe(int perStripe) } } +int Positioner::stripes() const +{ + return m_stripes; +} + +void Positioner::setStripes(int stripes) +{ + if (m_stripes != stripes) { + m_stripes = stripes; + + emit stripesChanged(); + + if (m_enabled && m_stripes > 0 && m_perStripe > 0 && !m_proxyToSource.isEmpty()) { + applyPositions(); + } + } +} + +// A cell is usable only while it is inside the grid the view currently has +// room for; anything outside has to be reflowed or it would sit off-screen. +bool Positioner::fitsGrid(int stripe, int pos) const +{ + if (stripe < 0 || pos < 0) + return false; + + if (m_perStripe > 0 && pos >= m_perStripe) + return false; + + if (m_stripes > 0 && stripe >= m_stripes) + return false; + + return true; +} + QStringList Positioner::positions() const { return m_positions; @@ -147,6 +183,15 @@ int Positioner::map(int row) const return row; } +int Positioner::mapFromSource(int row) const +{ + if (m_enabled && m_folderModel) { + return m_sourceToProxy.value(row, -1); + } + + return row; +} + int Positioner::nearestItem(int currentIndex, Qt::ArrowType direction) { if (!m_enabled || currentIndex >= rowCount()) { @@ -307,8 +352,29 @@ QVariant Positioner::data(const QModelIndex &index, int role) const if (m_enabled) { if (m_proxyToSource.contains(index.row())) { return m_folderModel->data(m_folderModel->index(m_proxyToSource.value(index.row()), 0), role); - } else if (role == FolderModel::BlankRole) { + } + + // An empty cell still has to answer with the type the delegate + // expects, or every binding on it warns about an undefined value. + switch (role) { + case FolderModel::BlankRole: return true; + case FolderModel::SelectedRole: + case FolderModel::IsDirRole: + case FolderModel::IsHiddenRole: + case FolderModel::IsLinkRole: + case FolderModel::IsDesktopFileRole: + return false; + case FolderModel::UrlRole: + case FolderModel::DisplayNameRole: + case FolderModel::FileNameRole: + case FolderModel::FileSizeRole: + case FolderModel::IconNameRole: + case FolderModel::ThumbnailRole: + case FolderModel::ModifiedRole: + return QString(); + default: + break; } } else { return m_folderModel->data(m_folderModel->index(index.row(), 0), role); @@ -411,8 +477,23 @@ void Positioner::move(const QVariantList &moves) /* find the next blank space * we won't be happy if we're moving two icons to the same place */ + const int cells = (m_perStripe > 0 && m_stripes > 0) ? (m_perStripe * m_stripes) : 0; + int tried = 0; + while ((!isBlank(to) && from != to) || toIndices.contains(to)) { to++; + + // Wrap inside the grid: an occupied cell at the far corner must + // not push the icon into a column the desktop cannot show. + if (cells > 0 && to >= cells) { + to = 0; + } + + if (cells > 0 && ++tried > cells) { + // Every cell is taken; park it after the last one. + to = lastRow() + 1; + break; + } } } @@ -460,22 +541,23 @@ void Positioner::updatePositions() positions.append(QString::number((1 + ((rowCount() - 1) / m_perStripe)))); positions.append(QString::number(m_perStripe)); - QHashIterator it(m_proxyToSource); + // Sorted by cell so the stored layout is stable: an unchanged desktop + // must not produce a different string list every time. + QList rows(m_proxyToSource.keys()); + std::sort(rows.begin(), rows.end()); - while (it.hasNext()) { - it.next(); - - const QString &name = m_folderModel->data(m_folderModel->index(it.value(), 0), FolderModel::UrlRole).toString(); + for (int row : std::as_const(rows)) { + const QString &name = m_folderModel->data(m_folderModel->index(m_proxyToSource.value(row), 0), FolderModel::UrlRole).toString(); if (name.isEmpty()) { - qDebug() << this << it.value() << "Source model doesn't know this index!"; + qDebug() << this << m_proxyToSource.value(row) << "Source model doesn't know this index!"; return; } positions.append(name); - positions.append(QString::number(qMax(0, it.key() / m_perStripe))); - positions.append(QString::number(qMax(0, it.key() % m_perStripe))); + positions.append(QString::number(qMax(0, row / m_perStripe))); + positions.append(QString::number(qMax(0, row % m_perStripe))); } } @@ -488,7 +570,7 @@ void Positioner::updatePositions() void Positioner::sourceStatusChanged() { - if (m_deferApplyPositions && m_folderModel->status() != FolderModel::Listing) { + if (m_folderModel->status() != FolderModel::Listing && (m_deferApplyPositions || m_positions.size() >= 5)) { applyPositions(); } @@ -501,6 +583,12 @@ void Positioner::sourceStatusChanged() void Positioner::sourceDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles) { if (m_enabled) { + // A rename keeps the icon where it is but changes the url the stored + // layout knows it by, so the records have to be rewritten. + if (roles.isEmpty() || roles.contains(FolderModel::UrlRole)) { + m_updatePositionsTimer->start(); + } + int start = topLeft.row(); int end = bottomRight.row(); @@ -518,16 +606,24 @@ void Positioner::sourceDataChanged(const QModelIndex &topLeft, const QModelIndex void Positioner::sourceModelAboutToBeReset() { - emit beginResetModel(); + beginResetModel(); } void Positioner::sourceModelReset() { if (m_enabled) { - initMaps(); + QHash proxyToSource; + QHash sourceToProxy; + + if (computeMaps(&proxyToSource, &sourceToProxy)) { + m_proxyToSource = proxyToSource; + m_sourceToProxy = sourceToProxy; + } else { + initMaps(); + } } - emit endResetModel(); + endResetModel(); } void Positioner::sourceRowsAboutToBeInserted(const QModelIndex &parent, int start, int end) @@ -588,7 +684,6 @@ void Positioner::sourceRowsAboutToBeInserted(const QModelIndex &parent, int star m_ignoreNextTransaction = true; } } else { - emit beginInsertRows(parent, start, end); beginInsertRows(parent, start, end); m_beginInsertRowsCalled = true; } @@ -643,7 +738,7 @@ void Positioner::sourceRowsAboutToBeRemoved(const QModelIndex &parent, int first m_ignoreNextTransaction = true; } } else { - emit beginRemoveRows(parent, first, last); + beginRemoveRows(parent, first, last); } } @@ -710,8 +805,18 @@ void Positioner::sourceLayoutChanged(const QList &parents { Q_UNUSED(parents) + // A re-sort or re-filter renumbers the source rows; the icons stay on their + // cells, so remap by url instead of repacking the whole desktop. if (m_enabled) { - initMaps(); + QHash proxyToSource; + QHash sourceToProxy; + + if (computeMaps(&proxyToSource, &sourceToProxy)) { + m_proxyToSource = proxyToSource; + m_sourceToProxy = sourceToProxy; + } else { + initMaps(); + } } emit layoutChanged(QList(), hint); @@ -753,6 +858,8 @@ int Positioner::firstRow() const return -1; } +// -1 when nothing is mapped, so that lastRow() + 1 is the first free cell and +// the row count of an empty grid, rather than one phantom cell in both. int Positioner::lastRow() const { if (!m_proxyToSource.isEmpty()) { @@ -761,7 +868,7 @@ int Positioner::lastRow() const return keys.last(); } - return 0; + return -1; } int Positioner::firstFreeRow() const @@ -789,7 +896,10 @@ void Positioner::applyPositions() return; } - if (m_positions.size() < 5) { + QHash proxyToSource; + QHash sourceToProxy; + + if (!computeMaps(&proxyToSource, &sourceToProxy)) { // We were waiting for listing to complete before proxying source rows, // but we don't have positions to apply. Reset to populate. if (m_deferApplyPositions) { @@ -800,110 +910,194 @@ void Positioner::applyPositions() return; } - beginResetModel(); + // Resetting the model tears down every delegate -- and with them the rename + // editor -- so only do it when the cells actually move. + if (proxyToSource != m_proxyToSource) { + beginResetModel(); - m_proxyToSource.clear(); - m_sourceToProxy.clear(); + m_proxyToSource = proxyToSource; + m_sourceToProxy = sourceToProxy; + + endResetModel(); + } - const QStringList &positions = m_positions.mid(2); + m_deferApplyPositions = false; - if (positions.count() % 3 != 0) { - return; + m_updatePositionsTimer->start(); +} + +// Works out which source row belongs in which cell from the stored positions, +// without touching the live maps: the caller decides how to announce the change. +bool Positioner::computeMaps(QHash *proxyToSource, QHash *sourceToProxy) const +{ + if (m_positions.size() < 5) { + return false; } - QHash sourceIndices; + const QStringList positions = m_positions.mid(2); - for (int i = 0; i < m_folderModel->rowCount(); ++i) { - sourceIndices.insert(m_folderModel->data(m_folderModel->index(i, 0), FolderModel::UrlRole).toString(), i); + if (positions.count() % 3 != 0) { + return false; } - QString name; - int stripe = -1; - int pos = -1; - int sourceIndex = -1; - int index = -1; - bool ok = false; - int offset = 0; + proxyToSource->clear(); + sourceToProxy->clear(); - // Restore positions for items that still fit. - for (int i = 0; i < positions.count() / 3; ++i) { - offset = i * 3; - pos = positions.at(offset + 2).toInt(&ok); - if (!ok) { - return; + auto lastCell = [](const QHash &map) { + int last = -1; + for (auto it = map.cbegin(); it != map.cend(); ++it) { + last = qMax(last, it.key()); } + return last; + }; - if (pos <= m_perStripe) { - name = positions.at(offset); - stripe = positions.at(offset + 1).toInt(&ok); - if (!ok) { - return; + auto freeCell = [&lastCell](const QHash &map) { + const int last = lastCell(map); + for (int i = 0; i <= last; ++i) { + if (!map.contains(i)) { + return i; } + } + return last + 1; + }; + + // The free cell closest to (stripe, pos), clamped into the grid: an icon that + // no longer fits stays near where it was instead of piling up in the corner. + auto nearestFree = [&](int stripe, int pos, const QHash &map) { + if (m_perStripe <= 0 || m_stripes <= 0) { + return -1; + } - if (!sourceIndices.contains(name)) { - continue; - } else { - sourceIndex = sourceIndices.value(name); - } + stripe = qBound(0, stripe, m_stripes - 1); + pos = qBound(0, pos, m_perStripe - 1); + + for (int radius = 0; radius < m_stripes + m_perStripe; ++radius) { + int best = -1; + int bestDistance = 0; + + for (int ds = -radius; ds <= radius; ++ds) { + for (int dp = -radius; dp <= radius; ++dp) { + if (qMax(qAbs(ds), qAbs(dp)) != radius) { + continue; + } + + const int s = stripe + ds; + const int p = pos + dp; - index = (stripe * m_perStripe) + pos; + if (s < 0 || p < 0 || s >= m_stripes || p >= m_perStripe) { + continue; + } - if (m_proxyToSource.contains(index)) { - continue; + const int cell = (s * m_perStripe) + p; + + if (map.contains(cell)) { + continue; + } + + const int distance = (ds * ds) + (dp * dp); + + if (best == -1 || distance < bestDistance) { + best = cell; + bestDistance = distance; + } + } } - updateMaps(index, sourceIndex); - sourceIndices.remove(name); + if (best != -1) { + return best; + } } + + return -1; + }; + + auto place = [&](int cell, int sourceRow) { + proxyToSource->insert(cell, sourceRow); + sourceToProxy->insert(sourceRow, cell); + }; + + QHash sourceIndices; + + for (int i = 0; i < m_folderModel->rowCount(); ++i) { + sourceIndices.insert(m_folderModel->data(m_folderModel->index(i, 0), FolderModel::UrlRole).toString(), i); } - // Find new positions for items that didn't fit. + // Records that no longer fit the current grid, with the cell they asked for. + struct Spill { + QString name; + int stripe; + int pos; + }; + QVector spilled; + for (int i = 0; i < positions.count() / 3; ++i) { - offset = i * 3; - pos = positions.at(offset + 2).toInt(&ok); + const int offset = i * 3; + const QString name = positions.at(offset); + + bool ok = false; + const int stripe = positions.at(offset + 1).toInt(&ok); if (!ok) { - return; + continue; } - if (pos > m_perStripe) { - name = positions.at(offset); + const int pos = positions.at(offset + 2).toInt(&ok); + if (!ok) { + continue; + } - if (!sourceIndices.contains(name)) { - continue; - } else { - sourceIndex = sourceIndices.take(name); - } + if (!sourceIndices.contains(name)) { + continue; + } - index = firstFreeRow(); + if (!fitsGrid(stripe, pos)) { + spilled.append({name, stripe, pos}); + continue; + } - if (index == -1) { - index = lastRow() + 1; - } + const int cell = (stripe * m_perStripe) + pos; - updateMaps(index, sourceIndex); + if (proxyToSource->contains(cell)) { + spilled.append({name, stripe, pos}); + continue; } - } - QHashIterator it(sourceIndices); + place(cell, sourceIndices.take(name)); + } - // Find positions for new source items we don't have records for. - while (it.hasNext()) { - it.next(); + // Find new cells for the items that didn't fit, then for source items we + // have no record of at all. + for (const Spill &spill : std::as_const(spilled)) { + if (!sourceIndices.contains(spill.name)) { + continue; + } - index = firstFreeRow(); + int cell = nearestFree(spill.stripe, spill.pos, *proxyToSource); - if (index == -1) { - index = lastRow() + 1; + if (cell < 0) { + cell = freeCell(*proxyToSource); } - updateMaps(index, it.value()); + place(cell, sourceIndices.take(spill.name)); } - endResetModel(); + QStringList newNames = sourceIndices.keys(); + std::sort(newNames.begin(), newNames.end()); - m_deferApplyPositions = false; + for (const QString &name : std::as_const(newNames)) { + const int sourceRow = sourceIndices.value(name); - m_updatePositionsTimer->start(); + // A renamed file has no record under its new url, but it is already on + // a cell and must stay there; only genuinely new items get a free cell. + int cell = m_sourceToProxy.value(sourceRow, -1); + + if (cell < 0 || proxyToSource->contains(cell) || (m_perStripe > 0 && !fitsGrid(cell / m_perStripe, cell % m_perStripe))) { + cell = freeCell(*proxyToSource); + } + + place(cell, sourceRow); + } + + return true; } void Positioner::flushPendingChanges() @@ -926,6 +1120,8 @@ void Positioner::flushPendingChanges() void Positioner::connectSignals(FolderModel *model) { connect(model, &QAbstractItemModel::dataChanged, this, &Positioner::sourceDataChanged, Qt::UniqueConnection); + connect(model, &QAbstractItemModel::modelAboutToBeReset, this, &Positioner::sourceModelAboutToBeReset, Qt::UniqueConnection); + connect(model, &QAbstractItemModel::modelReset, this, &Positioner::sourceModelReset, Qt::UniqueConnection); connect(model, &QAbstractItemModel::rowsAboutToBeInserted, this, &Positioner::sourceRowsAboutToBeInserted, Qt::UniqueConnection); connect(model, &QAbstractItemModel::rowsAboutToBeMoved, this, &Positioner::sourceRowsAboutToBeMoved, Qt::UniqueConnection); connect(model, &QAbstractItemModel::rowsAboutToBeRemoved, this, &Positioner::sourceRowsAboutToBeRemoved, Qt::UniqueConnection); @@ -941,6 +1137,8 @@ void Positioner::connectSignals(FolderModel *model) void Positioner::disconnectSignals(FolderModel *model) { disconnect(model, &QAbstractItemModel::dataChanged, this, &Positioner::sourceDataChanged); + disconnect(model, &QAbstractItemModel::modelAboutToBeReset, this, &Positioner::sourceModelAboutToBeReset); + disconnect(model, &QAbstractItemModel::modelReset, this, &Positioner::sourceModelReset); disconnect(model, &QAbstractItemModel::rowsAboutToBeInserted, this, &Positioner::sourceRowsAboutToBeInserted); disconnect(model, &QAbstractItemModel::rowsAboutToBeMoved, this, &Positioner::sourceRowsAboutToBeMoved); disconnect(model, &QAbstractItemModel::rowsAboutToBeRemoved, this, &Positioner::sourceRowsAboutToBeRemoved); diff --git a/model/positioner.h b/model/positioner.h index 04d6924..bd47412 100644 --- a/model/positioner.h +++ b/model/positioner.h @@ -21,6 +21,7 @@ #define POSITIONER_H #include +#include class FolderModel; class QTimer; @@ -30,6 +31,7 @@ class Positioner : public QAbstractItemModel Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged) Q_PROPERTY(FolderModel *folderModel READ folderModel WRITE setFolderModel NOTIFY folderModelChanged) Q_PROPERTY(int perStripe READ perStripe WRITE setPerStripe NOTIFY perStripeChanged) + Q_PROPERTY(int stripes READ stripes WRITE setStripes NOTIFY stripesChanged) Q_PROPERTY(QStringList positions READ positions WRITE setPositions NOTIFY positionsChanged) public: @@ -45,10 +47,14 @@ public: int perStripe() const; void setPerStripe(int perStripe); + int stripes() const; + void setStripes(int stripes); + QStringList positions() const; void setPositions(const QStringList &positions); Q_INVOKABLE int map(int row) const; + Q_INVOKABLE int mapFromSource(int row) const; Q_INVOKABLE int nearestItem(int currentIndex, Qt::ArrowType direction); @@ -75,6 +81,7 @@ signals: void enabledChanged() const; void folderModelChanged() const; void perStripeChanged() const; + void stripesChanged() const; void positionsChanged() const; private slots: @@ -99,6 +106,8 @@ private: int lastRow() const; int firstFreeRow() const; void applyPositions(); + bool computeMaps(QHash *proxyToSource, QHash *sourceToProxy) const; + bool fitsGrid(int stripe, int pos) const; void flushPendingChanges(); void connectSignals(FolderModel *model); void disconnectSignals(FolderModel *model); @@ -107,6 +116,7 @@ private: FolderModel *m_folderModel; int m_perStripe; + int m_stripes; int m_lastRow; diff --git a/qml/Desktop/Main.qml b/qml/Desktop/Main.qml index 0f823f7..e77ec1d 100644 --- a/qml/Desktop/Main.qml +++ b/qml/Desktop/Main.qml @@ -22,6 +22,8 @@ import QtQuick.Controls 2.12 import QtQuick.Layouts 1.12 import QtQuick.Window 2.12 +import QtCore + import Cutefish.FileManager 1.0 as FM import FishUI 1.0 as FishUI import "../" @@ -32,10 +34,85 @@ Item { LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft LayoutMirroring.childrenInherit: true + // Icon positions are stored per screen and per grid size, so switching + // resolution -- or icon size -- picks up the arrangement that belongs to it + // and switching back restores the previous one untouched. + readonly property string screenName: (typeof desktopView !== "undefined" && desktopView) + ? String(desktopView.screenName).replace(/[\/\\]/g, "_") : "" + readonly property string layoutKey: (_folderView.width > 0 && _folderView.height > 0) + ? "%1_%2x%3".arg(screenName).arg(_folderView.gridColumns).arg(_folderView.gridRows) + : "" + + property bool restoringLayout: false + GlobalSettings { id: globalSettings } + Settings { + id: layoutSettings + location: globalSettings.location + category: "DesktopIconLayout" + } + + Timer { + id: saveLayoutTimer + interval: 500 + onTriggered: rootItem.saveLayout() + } + + onLayoutKeyChanged: loadLayout() + + Component.onCompleted: loadLayout() + + function loadLayout() { + if (!layoutKey) + return + + restoringLayout = true + + var raw = layoutSettings.value("layout." + layoutKey, "") + + if (raw) { + try { + var stored = JSON.parse(raw) + if (stored instanceof Array && stored.length >= 5) + _folderView.positioner.positions = stored + } catch (e) { + console.warn("Desktop: ignoring unreadable icon layout for", layoutKey, e) + } + } + + restoringLayout = false + + // Whatever we ended up with -- the stored layout, or the previous one + // reflowed into the new grid -- belongs to this profile from now on. + saveLayoutTimer.restart() + } + + function saveLayout() { + if (!layoutKey || restoringLayout) + return + + var positions = _folderView.positioner.positions + + // Fewer than one full record means the folder has not been listed yet; + // saving that would wipe the layout of an empty desktop. + if (positions.length < 5) + return + + layoutSettings.setValue("layout." + layoutKey, JSON.stringify(positions)) + } + + Connections { + target: _folderView.positioner + + function onPositionsChanged() { + if (!rootItem.restoringLayout) + saveLayoutTimer.restart() + } + } + FM.FolderModel { id: dirModel url: desktopPath() @@ -44,14 +121,14 @@ Item { viewAdapter: viewAdapter onCurrentIndexChanged: { - _folderView.currentIndex = dirModel.currentIndex + _folderView.currentIndex = _folderView.positioner.mapFromSource(dirModel.currentIndex) } } FM.ItemViewAdapter { id: viewAdapter adapterView: _folderView - adapterModel: dirModel + adapterModel: _folderView.positioner adapterIconSize: 40 adapterVisibleArea: Qt.rect(_folderView.contentX, _folderView.contentY, _folderView.contentWidth, _folderView.contentHeight) @@ -82,7 +159,7 @@ Item { maximumIconSize: globalSettings.maximumIconSize minimumIconSize: 22 focus: true - model: dirModel + model: _folderView.positioner ScrollBar.vertical.policy: ScrollBar.AlwaysOff diff --git a/qml/FolderGridItem.qml b/qml/FolderGridItem.qml index 3198848..02b7590 100644 --- a/qml/FolderGridItem.qml +++ b/qml/FolderGridItem.qml @@ -44,20 +44,28 @@ Item { // For desktop visible: GridView.view.isDesktopView ? !blank : true - onSelectedChanged: { - if (!GridView.view.isDesktopView) + onSelectedChanged: updateDragImage() + // blank and selected do not settle in the same order, so both have to ask. + onBlankChanged: updateDragImage() + + // The drag pixmap is built from these snapshots, so one taken at the icon's + // old cell would drag the wrong picture at the wrong offset from the cursor. + onXChanged: updateDragImage() + onYChanged: updateDragImage() + + function updateDragImage() { + if (!GridView.view.isDesktopView || !selected || blank) return - if (selected && !blank) { - control.grabToImage(function(result) { - dirModel.addItemDragImage(control.index, - control.x, - control.y, - control.width, - control.height, - result.image) - }) - } + var row = GridView.view.positioner.map(control.index) + var x = control.x + var y = control.y + var width = control.width + var height = control.height + + control.grabToImage(function(result) { + dirModel.addItemDragImage(row, x, y, width, height, result.image) + }) } Rectangle { diff --git a/qml/FolderGridView.qml b/qml/FolderGridView.qml index 5a41d9a..15f5d9a 100644 --- a/qml/FolderGridView.qml +++ b/qml/FolderGridView.qml @@ -39,6 +39,18 @@ GridView { property alias positions: positioner.positions property alias positioner: positioner + // The cell grid the view has room for. cellWidth/cellHeight are stretched to + // fill exactly this many cells, so this is also what the positioner stores + // icon cells against. + readonly property int gridColumns: Math.max(1, Math.floor((width - leftMargin - rightMargin) / cellWidth)) + readonly property int gridRows: Math.max(1, Math.floor((height - topMargin - bottomMargin) / cellHeight)) + readonly property bool columnFlow: control.flow === GridView.FlowTopToBottom + + // Where inside its cell the icon was grabbed, so a drop lands the icon under + // the cursor rather than offset by the grab point. + property int dragAnchorIndex: -1 + property point dragGrabOffset: Qt.point(0, 0) + property int verticalDropHitscanOffset: 0 property int pressX: -1 @@ -73,11 +85,14 @@ GridView { onIconSizeChanged: { // 图标大小改变时需要重置状态,否则选中定位不准确 - positioner.reset() + // 桌面有自己的位置记忆,重置会丢弃用户摆放的位置 + if (!positioner.enabled) + positioner.reset() } onCountChanged: { - positioner.reset() + if (!positioner.enabled) + positioner.reset() } function effectiveNavDirection(flow, layoutDirection, direction) { @@ -126,7 +141,7 @@ GridView { function rename() { if (control.currentIndex != -1) { - var renameAction = control.model.action("rename") + var renameAction = dirModel.action("rename") if (renameAction && !renameAction.enabled) return @@ -144,7 +159,12 @@ GridView { } } - function openContextMenu(modifiers) { + // x, y are the click that opened the menu, in view coordinates. + function openContextMenu(modifiers, x, y) { + // A new folder or file belongs on the cell that was right-clicked. + if (positioner.enabled) + dirModel.setNewDocumentPosition(x, y) + if (control.useCustomContextMenu) { dirModel.prepareContextMenu() control.contextMenuRequested() @@ -317,16 +337,99 @@ GridView { if (cachedRectangleSelection.length) control.currentIndex[0] - dirModel.updateSelection(cachedRectangleSelection, control.ctrlPressed) + dirModel.updateSelection(cachedRectangleSelection.map(function(index) { + return positioner.map(index) + }), control.ctrlPressed) } Positioner { id: positioner - enabled: true + // Only the desktop keeps icons in fixed cells; in the window the view + // stays a plain, always-packed grid. + enabled: control.isDesktopView folderModel: dirModel - perStripe: Math.floor(((control.flow == GridView.FlowLeftToRight) - ? control.width : control.height) / ((control.flow == GridView.FlowLeftToRight) - ? control.cellWidth : control.cellHeight)) + perStripe: control.columnFlow ? control.gridRows : control.gridColumns + stripes: control.columnFlow ? control.gridColumns : control.gridRows + } + + // Cell containing a point in content coordinates. + function cellAt(cx, cy) { + return clampCell(Math.floor(cx / control.cellWidth), Math.floor(cy / control.cellHeight)) + } + + // Cell an icon whose top left corner is at cx, cy snaps to: the nearest one, + // so half a cell of travel is enough to move it, the way a drag should feel. + function cellNear(cx, cy) { + return clampCell(Math.round(cx / control.cellWidth), Math.round(cy / control.cellHeight)) + } + + function clampCell(col, row) { + col = Math.max(0, Math.min(control.gridColumns - 1, col)) + row = Math.max(0, Math.min(control.gridRows - 1, row)) + + if (control.effectiveLayoutDirection === Qt.RightToLeft) + col = control.gridColumns - 1 - col + + return Qt.point(col, row) + } + + function cellIndex(col, row) { + return control.columnFlow ? (col * control.gridRows) + row + : (row * control.gridColumns) + col + } + + function cellOfIndex(index) { + return control.columnFlow ? Qt.point(Math.floor(index / control.gridRows), index % control.gridRows) + : Qt.point(index % control.gridColumns, Math.floor(index / control.gridColumns)) + } + + // A drop inside the view: pin the dropped icons to the cell under the cursor, + // keeping the relative arrangement of a multiple selection. anchorIndex is + // the cell the drag was started from, or -1 for a drop that comes from + // somewhere else -- those land on the cell under the cursor. + function moveToCell(x, y, urls, anchorIndex, grabOffset) { + if (!positioner.enabled) + return + + // A drag that started on one of the icons keeps the selection's shape: + // every icon moves by the same number of cells as the grabbed one. + var anchor = anchorIndex >= 0 ? cellOfIndex(anchorIndex) : null + var grab = anchor ? grabOffset : Qt.point(0, 0) + var pos = mapToItem(control.contentItem, x, y) + // With an anchor the drag carries the icon itself, so snap the icon; a + // drop from elsewhere only has a cursor, which lands on the cell it is over. + var target = anchor ? cellNear(pos.x - grab.x, pos.y - grab.y) : cellAt(pos.x, pos.y) + var moves = [] + + for (var i = 0; i < urls.length; ++i) { + var from = positioner.indexForUrl(urls[i]) + + if (from === -1) + continue + + var cell = cellOfIndex(from) + var col = anchor ? target.x + (cell.x - anchor.x) : target.x + var row = anchor ? target.y + (cell.y - anchor.y) : target.y + + col = Math.max(0, Math.min(control.gridColumns - 1, col)) + row = Math.max(0, Math.min(control.gridRows - 1, row)) + + moves.push(from) + moves.push(cellIndex(col, row)) + } + + if (moves.length) + positioner.move(moves) + } + + Connections { + target: positioner.enabled ? dirModel : null + + function onMove(x, y, urls) { + control.moveToCell(x, y, urls, + dirModel.dragging ? control.dragAnchorIndex : -1, + control.dragGrabOffset) + } } DragDrop.DropArea { @@ -366,25 +469,25 @@ GridView { // Shift 处理, 选择区域 if (control.shiftPressed && control.currentIndex !== -1) { - dirModel.setRangeSelected(control.anchorIndex, hoveredItem.index) + positioner.setRangeSelected(control.anchorIndex, hoveredItem.index) } else { // Ctrl 处理 - if (!control.ctrlPressed && !dirModel.isSelected(hoveredItem.index)) { + if (!control.ctrlPressed && !dirModel.isSelected(positioner.map(hoveredItem.index))) { dirModel.clearSelection() } // Item 选择 if (control.ctrlPressed) { - dirModel.toggleSelected(hoveredItem.index) + dirModel.toggleSelected(positioner.map(hoveredItem.index)) } else { - dirModel.setSelected(hoveredItem.index) + dirModel.setSelected(positioner.map(hoveredItem.index)) } } // 弹出 Item 菜单 if (mouse.buttons & Qt.RightButton) { clearPressState() - control.openContextMenu(mouse.modifiers) + control.openContextMenu(mouse.modifiers, mouse.x, mouse.y) mouse.accepted = true } } else { @@ -397,7 +500,7 @@ GridView { // 弹出文件夹菜单 if (mouse.buttons & Qt.RightButton) { clearPressState() - control.openContextMenu(mouse.modifiers) + control.openContextMenu(mouse.modifiers, mouse.x, mouse.y) mouse.accepted = true } } @@ -461,10 +564,16 @@ GridView { } if (pressX != -1) { - if (pressedItem != null && dirModel.isSelected(pressedItem.index)) { + if (pressedItem != null && dirModel.isSelected(positioner.map(pressedItem.index))) { control.dragX = mouse.x control.dragY = mouse.y control.verticalDropHitscanOffset = pressedItem.y + (pressedItem.height / 2) + + var grab = mapToItem(pressedItem, mouse.x, mouse.y) + control.dragAnchorIndex = pressedItem.index + control.dragGrabOffset = Qt.point(grab.x, grab.y) + + control.refreshDragImages() dirModel.dragSelected(mouse.x, mouse.y) control.dragX = -1 control.dragY = -1 @@ -496,7 +605,7 @@ GridView { !control.ctrlPressed && !dirModel.dragging) { dirModel.clearSelection() - dirModel.setSelected(pressedItem.index) + dirModel.setSelected(positioner.map(pressedItem.index)) } dirModel.updateSelectedItemsSize() @@ -518,6 +627,25 @@ GridView { } } + // The drag pixmap is assembled from snapshots the delegates take when they + // are selected. An icon that has since been moved to another cell still + // carries its old geometry, which would drag the picture far off the cursor, + // so re-sync every selected icon just before the drag starts. + function refreshDragImages() { + // The snapshots are in content coordinates, the drag cursor is not. + dirModel.setDragHotSpotScrollOffset(control.contentX, control.contentY) + + for (var i = 0; i < control.count; ++i) { + var item = control.itemAtIndex(i) + + if (!item || item.blank || !item.selected) + continue + + if (!dirModel.updateItemDragRect(positioner.map(i), item.x, item.y, item.width, item.height)) + item.updateDragImage() + } + } + function clearPressState() { pressedItem = null pressX = -1 @@ -556,7 +684,7 @@ GridView { break } - if (dirModel.isBlank(index)) { + if (positioner.isBlank(index)) { continue } @@ -635,10 +763,10 @@ GridView { function updateSelection(modifier) { if (modifier & Qt.ShiftModifier) { - dirModel.setRangeSelected(anchorIndex, currentIndex) + positioner.setRangeSelected(anchorIndex, currentIndex) } else { dirModel.clearSelection() - dirModel.setSelected(currentIndex) + dirModel.setSelected(positioner.map(currentIndex)) } } @@ -665,7 +793,7 @@ GridView { y = pos.y - FishUI.Units.smallSpacing text = targetItem.labelArea.text targetItem.labelArea.visible = false - _editor.select(0, dirModel.fileExtensionBoundary(targetItem.index)) + _editor.select(0, dirModel.fileExtensionBoundary(positioner.map(targetItem.index))) visible = true } else { x = 0 @@ -700,7 +828,7 @@ GridView { function commit() { if (targetItem) { targetItem.labelArea.visible = true - dirModel.rename(targetItem.index, text) + dirModel.rename(positioner.map(targetItem.index), text) control.currentIndex = targetItem.index targetItem = null