feat(filemanager): pin desktop icons to grid cells with per-screen position memory

main
reionwong 3 weeks ago
parent f97f9f81aa
commit 5074b74fbf

@ -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<QUrl> urls = KUrlMimeData::urlsFromMimeData(mimeData);
QList<QUrl> urls = mimeData ? KUrlMimeData::urlsFromMimeData(mimeData) : QList<QUrl>();
const QString &currentUrl = 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<QUrl> urls = KUrlMimeData::urlsFromMimeData(mimeData);
QList<QUrl> urls = mimeData ? KUrlMimeData::urlsFromMimeData(mimeData) : QList<QUrl>();
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());

@ -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<QUrl> m_needSelectUrls;
Status m_status;

@ -24,12 +24,14 @@
#include <QTimer>
#include <cstdlib>
#include <utility>
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<int, int> 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<int> 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<int> &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<int, int> proxyToSource;
QHash<int, int> 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<QPersistentModelIndex> &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<int, int> proxyToSource;
QHash<int, int> sourceToProxy;
if (computeMaps(&proxyToSource, &sourceToProxy)) {
m_proxyToSource = proxyToSource;
m_sourceToProxy = sourceToProxy;
} else {
initMaps();
}
}
emit layoutChanged(QList<QPersistentModelIndex>(), 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<int, int> proxyToSource;
QHash<int, int> 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<int, int> *proxyToSource, QHash<int, int> *sourceToProxy) const
{
if (m_positions.size() < 5) {
return false;
}
QHash<QString, int> 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<int, int> &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<int, int> &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<int, int> &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<QString, int> 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<Spill> 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<QString, int> 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);

@ -21,6 +21,7 @@
#define POSITIONER_H
#include <QAbstractItemModel>
#include <QHash>
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<int, int> *proxyToSource, QHash<int, int> *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;

@ -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

@ -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 {

@ -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

Loading…
Cancel
Save