From 07a1216787d5eda15f51d4e02c13885c45bf64d4 Mon Sep 17 00:00:00 2001 From: reionwong Date: Sat, 5 Sep 2026 02:31:50 -0400 Subject: [PATCH] fix(desktop): restore arrow key navigation and place new icons top right --- model/foldermodel.cpp | 7 +- model/positioner.cpp | 153 +++++++++++++++++++++++++++++++++----- model/positioner.h | 4 +- qml/Desktop/Main.qml | 31 ++++++-- qml/FolderContextMenu.qml | 4 +- 5 files changed, 169 insertions(+), 30 deletions(-) diff --git a/model/foldermodel.cpp b/model/foldermodel.cpp index 7901fd1..a9c4a79 100644 --- a/model/foldermodel.cpp +++ b/model/foldermodel.cpp @@ -2143,7 +2143,7 @@ void FolderModel::createActions() QAction *restore = new QAction(tr("Restore"), this); QObject::connect(restore, &QAction::triggered, this, &FolderModel::restoreFromTrash); - QAction *showHidden = new QAction(tr("Show hidden files"), this); + QAction *showHidden = new QAction(tr("Show Hidden Files"), this); QObject::connect(showHidden, &QAction::triggered, this, [=] { setShowHiddenFiles(!m_showHiddenFiles); }); @@ -2304,8 +2304,9 @@ void FolderModel::updateActions() if (QAction *showHidden = m_actionCollection.action("showHidden")) { showHidden->setVisible(!isTrash); - showHidden->setCheckable(true); - showHidden->setChecked(m_showHiddenFiles); + // Plain text instead of a check mark: the label says what the item does. + showHidden->setText(m_showHiddenFiles ? tr("Hide Hidden Files") + : tr("Show Hidden Files")); } if (QAction *openInNewWindow = m_actionCollection.action("openInNewWindow")) { diff --git a/model/positioner.cpp b/model/positioner.cpp index b56426d..fbedcbf 100644 --- a/model/positioner.cpp +++ b/model/positioner.cpp @@ -194,10 +194,45 @@ int Positioner::mapFromSource(int row) const int Positioner::nearestItem(int currentIndex, Qt::ArrowType direction) { - if (!m_enabled || currentIndex >= rowCount()) { + const int count = rowCount(); + + if (count <= 0 || currentIndex >= count) { return -1; } + // Without stored positions the grid is always packed, so navigation is + // plain index arithmetic along and across the stripes. + if (!m_enabled) { + if (m_perStripe <= 0) { + return -1; + } + + if (currentIndex < 0) { + return 0; + } + + int next = currentIndex; + + switch (direction) { + case Qt::LeftArrow: + next -= 1; + break; + case Qt::RightArrow: + next += 1; + break; + case Qt::UpArrow: + next -= m_perStripe; + break; + case Qt::DownArrow: + next += m_perStripe; + break; + default: + return -1; + } + + return (next >= 0 && next < count) ? next : -1; + } + if (currentIndex < 0) { return firstRow(); } @@ -634,10 +669,23 @@ void Positioner::sourceRowsAboutToBeInserted(const QModelIndex &parent, int star if (m_deferApplyPositions) { return; } else if (m_proxyToSource.isEmpty()) { - beginInsertRows(parent, start, end); + // First icons of an empty desktop. They go on their default cells, + // and the untaken cells before them are inserted as blanks so the + // announced range matches the row count that follows. + QHash cells; + int lastNew = -1; + + for (int i = start; i <= end; ++i) { + cells.insert(i, defaultCell(i - start)); + lastNew = qMax(lastNew, cells.value(i)); + } + + beginInsertRows(parent, 0, lastNew); m_beginInsertRowsCalled = true; - initMaps(end + 1); + for (int i = start; i <= end; ++i) { + updateMaps(cells.value(i), i); + } return; } @@ -671,14 +719,33 @@ void Positioner::sourceRowsAboutToBeInserted(const QModelIndex &parent, int star } if (rest != -1) { - int firstNew = lastRow() + 1; - int remainder = (end - rest); + const int firstNew = lastRow() + 1; + const int remainder = (end - rest); + + // Nothing is free inside the existing rows, so the new icons take + // default cells beyond them; every cell up to the last one used is + // part of the insertion, blank or not. + QHash occupied = m_proxyToSource; + QList cells; + int lastNew = firstNew + remainder; + + for (int i = 0; i <= remainder; ++i) { + int cell = nextDefaultCell(occupied); + + if (cell < firstNew) { + cell = firstNew + i; + } - beginInsertRows(parent, firstNew, firstNew + remainder); + occupied.insert(cell, rest + i); + cells.append(cell); + lastNew = qMax(lastNew, cell); + } + + beginInsertRows(parent, firstNew, lastNew); m_beginInsertRowsCalled = true; for (int i = 0; i <= remainder; ++i) { - updateMaps(firstNew + i, rest + i); + updateMaps(cells.at(i), rest + i); } } else { m_ignoreNextTransaction = true; @@ -822,22 +889,56 @@ void Positioner::sourceLayoutChanged(const QList &parents emit layoutChanged(QList(), hint); } -void Positioner::initMaps(int size) +void Positioner::initMaps() { m_proxyToSource.clear(); m_sourceToProxy.clear(); - if (size == -1) { - size = m_folderModel->rowCount(); + const int size = m_folderModel->rowCount(); + + for (int i = 0; i < size; ++i) { + updateMaps(defaultCell(i), i); } +} - if (!size) { - return; +// Cell of the nth default slot: the rightmost stripe first, filled from its +// start, then the stripe next to it -- where macOS puts new desktop icons. +// Falls back to plain packing while the grid size is still unknown. +int Positioner::defaultCell(int ordinal) const +{ + if (m_perStripe <= 0 || m_stripes <= 0) { + return ordinal; } - for (int i = 0; i < size; ++i) { - updateMaps(i, i); + // More icons than the grid has room for: the extra ones spill past it, + // where they were piling up before there was a default order. + if (ordinal >= m_perStripe * m_stripes) { + return ordinal; } + + const int stripe = m_stripes - 1 - (ordinal / m_perStripe); + + return (stripe * m_perStripe) + (ordinal % m_perStripe); +} + +// The first default slot no icon sits on. +int Positioner::nextDefaultCell(const QHash &occupied) const +{ + if (m_perStripe <= 0 || m_stripes <= 0) { + return -1; + } + + const int cellCount = m_perStripe * m_stripes; + + for (int ordinal = 0; ordinal < cellCount; ++ordinal) { + const int cell = defaultCell(ordinal); + + if (!occupied.contains(cell)) { + return cell; + } + } + + return -1; } void Positioner::updateMaps(int proxyIndex, int sourceIndex) @@ -873,9 +974,21 @@ int Positioner::lastRow() const int Positioner::firstFreeRow() const { - if (!m_proxyToSource.isEmpty()) { - int last = lastRow(); + if (m_proxyToSource.isEmpty()) { + return -1; + } + const int last = lastRow(); + const int cell = nextDefaultCell(m_proxyToSource); + + // Past the rows the model already has: the caller has to announce an + // insertion for it, so leave it to the append path. + if (cell != -1) { + return cell <= last ? cell : -1; + } + + // No grid to go by: fall back to the first gap. + if (m_perStripe <= 0 || m_stripes <= 0) { for (int i = 0; i <= last; ++i) { if (!m_proxyToSource.contains(i)) { return i; @@ -951,7 +1064,13 @@ bool Positioner::computeMaps(QHash *proxyToSource, QHash *so return last; }; - auto freeCell = [&lastCell](const QHash &map) { + auto freeCell = [&lastCell, this](const QHash &map) { + const int cell = nextDefaultCell(map); + + if (cell != -1) { + return cell; + } + const int last = lastCell(map); for (int i = 0; i <= last; ++i) { if (!map.contains(i)) { diff --git a/model/positioner.h b/model/positioner.h index bd47412..9336ed0 100644 --- a/model/positioner.h +++ b/model/positioner.h @@ -100,11 +100,13 @@ private slots: void sourceLayoutChanged(const QList &parents, QAbstractItemModel::LayoutChangeHint hint); private: - void initMaps(int size = -1); + void initMaps(); void updateMaps(int proxyIndex, int sourceIndex); int firstRow() const; int lastRow() const; int firstFreeRow() const; + int defaultCell(int ordinal) const; + int nextDefaultCell(const QHash &occupied) const; void applyPositions(); bool computeMaps(QHash *proxyToSource, QHash *sourceToProxy) const; bool fitsGrid(int stripe, int pos) const; diff --git a/qml/Desktop/Main.qml b/qml/Desktop/Main.qml index e77ec1d..f53c829 100644 --- a/qml/Desktop/Main.qml +++ b/qml/Desktop/Main.qml @@ -63,7 +63,31 @@ Item { onLayoutKeyChanged: loadLayout() - Component.onCompleted: loadLayout() + Component.onCompleted: { + loadLayout() + takeFocus() + } + + // The desktop is built by DesktopView and only then parented into the + // window, so `focus: true` on the view never reaches the focus chain: + // claim it explicitly, and again whenever the window is activated. + function takeFocus() { + // Not while a name is being edited: the editor is a child of the view, + // and taking the focus back would close it. + if (_folderView.editor && _folderView.editor.targetItem) + return + + _folderView.forceActiveFocus() + } + + Connections { + target: rootItem.Window.window + + function onActiveChanged() { + if (rootItem.Window.window.active) + rootItem.takeFocus() + } + } function loadLayout() { if (!layoutKey) @@ -145,11 +169,6 @@ Item { hostWindow: rootItem.Window.window } - MouseArea { - anchors.fill: parent - onClicked: _folderView.forceActiveFocus() - } - FolderGridView { id: _folderView anchors.fill: parent diff --git a/qml/FolderContextMenu.qml b/qml/FolderContextMenu.qml index 55faa19..e7da85d 100644 --- a/qml/FolderContextMenu.qml +++ b/qml/FolderContextMenu.qml @@ -88,11 +88,9 @@ FishUI.DesktopMenu { FishUI.MenuItem { id: showHiddenItem property var modelAction: control.folderModel ? control.folderModel.action("showHidden") : null - text: modelAction ? modelAction.text : qsTr("Show hidden files") + text: modelAction ? modelAction.text : qsTr("Show Hidden Files") visible: !control.hasSelection && modelAction && modelAction.visible enabled: modelAction ? modelAction.enabled : false - checkable: true - checked: modelAction ? modelAction.checked : false onTriggered: modelAction.trigger() }