From edd94f9109ff02682afea6b7394909e26ba44e8b Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:35:52 -0400 Subject: [PATCH 01/20] Draw the selected name in the bright text color The selected item's name was drawn in the accent on the accent tint, which read dimmer than the names around it (3.8:1 on osaka-jade). It now uses the bright text color, and its size and date the normal one, as a lit menu row does. --- src/FileRow.qml | 6 +++--- src/FileTile.qml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/FileRow.qml b/src/FileRow.qml index a8dab284..6fec345d 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -117,7 +117,7 @@ Item { Layout.minimumWidth: 120 horizontalAlignment: Text.AlignLeft text: row.name - color: row.current ? theme.accent : theme.foreground + color: row.current ? theme.brightForeground : theme.foreground elide: Text.ElideMiddle } Text { @@ -125,7 +125,7 @@ Item { Layout.preferredWidth: 96 horizontalAlignment: Text.AlignRight text: row.kind === "item" ? row.extension : row.isDir ? Format.count(row.itemCount) : Format.size(row.size) - color: theme.secondaryForeground + color: row.current ? theme.foreground : theme.secondaryForeground font.pixelSize: 12 } Text { @@ -137,7 +137,7 @@ Item { translator.language // Re-read when the language (and so the default locale) changes. return Format.when(row.modified) } - color: theme.secondaryForeground + color: row.current ? theme.foreground : theme.secondaryForeground font.pixelSize: 12 elide: Text.ElideRight } diff --git a/src/FileTile.qml b/src/FileTile.qml index 4c85c7a5..dc55d3c8 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -74,7 +74,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter width: parent.width - 28 text: tile.name - color: tile.current ? theme.accent : theme.foreground + color: tile.current ? theme.brightForeground : theme.foreground font.pixelSize: 12 horizontalAlignment: Text.AlignHCenter wrapMode: Text.WrapAtWordBoundaryOrAnywhere @@ -101,7 +101,7 @@ Item { const date = tile.isDir ? "" : Format.when(tile.modified) return size && date ? qsTr("%1 · %2").arg(size).arg(date) : size || date } - color: theme.secondaryForeground + color: tile.current ? theme.foreground : theme.secondaryForeground font.pixelSize: 10 horizontalAlignment: Text.AlignHCenter elide: Text.ElideRight From 2364c925050e5ba11a2f4c96922bdf0b7df9482a Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:36:02 -0400 Subject: [PATCH 02/20] Scroll the cursor into view when keys move it Moving the cursor by key past the edge of the grid left it out of sight. The view now scrolls just enough to show it. --- src/Selecting.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Selecting.qml b/src/Selecting.qml index aa06c9e4..87a82eac 100644 --- a/src/Selecting.qml +++ b/src/Selecting.qml @@ -24,6 +24,8 @@ QtObject { return backend.folder.selectOnly(view.currentIndex) anchor = view.currentIndex + if (view.currentIndex >= 0) + view.positionViewAtIndex(view.currentIndex, ListView.Contain) } function click(index, modifiers) { From cc41cb169573d76952658f3ca2d0014a5d35dbd3 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:36:24 -0400 Subject: [PATCH 03/20] Frame the cursor's item when nothing is selected With nothing selected (Alt+click on the last selected item), Delete, F2 and Return still act on the cursor's item, but nothing showed which one. That item now carries a thin accent frame, the selection ring at 1 px. --- src/FileRow.qml | 4 +++- src/FileTile.qml | 14 ++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/FileRow.qml b/src/FileRow.qml index 6fec345d..4e3ee3e6 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -37,6 +37,8 @@ Item { // for it.) readonly property bool showDate: detailed && width >= 480 readonly property bool showSize: detailed && width >= 380 + // With nothing selected, keys act on the cursor's item; only the folder being shown has one. + readonly property bool cursor: ListView.isCurrentItem && ListView.view.model === backend.folder && backend.folder.selectedCount === 0 readonly property string glyph: row.isDir ? "folder" : row.kind === "item" ? row.icon @@ -61,7 +63,7 @@ Item { anchors.leftMargin: 4 anchors.rightMargin: 4 radius: Math.min(4, theme.radius) - border.width: folderDrop.containsDrag ? 2 : 0 + border.width: folderDrop.containsDrag ? 2 : row.cursor ? 1 : 0 border.color: theme.accent color: row.current || folderDrop.containsDrag ? Qt.alpha(theme.accent, 0.18) : row.trail ? Qt.alpha(theme.foreground, 0.08) diff --git a/src/FileTile.qml b/src/FileTile.qml index dc55d3c8..3222d992 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -32,6 +32,8 @@ Item { // Pictures and drawings round their corners with the theme: square on // Omarchy's default, softly rounded on themes that round windows. readonly property real iconRadius: Math.min(10, theme.radius) + // With nothing selected, keys act on the cursor's item. + readonly property bool cursor: GridView.isCurrentItem && backend.folder.selectedCount === 0 // The badge and stand-in color for each kind, from the theme's palette. readonly property color kindColor: kind === "pdf" ? theme.red @@ -178,8 +180,8 @@ Item { fillColor: tile.current || folderDrop.containsDrag ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.accent, 0.3)) : hover.hovered ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.foreground, 0.06)) : theme.lighterBackground - strokeColor: tile.current || folderDrop.containsDrag ? theme.accent : "transparent" - strokeWidth: folderDrop.containsDrag ? 2.5 : 1.5 + strokeColor: tile.current || tile.cursor || folderDrop.containsDrag ? theme.accent : "transparent" + strokeWidth: folderDrop.containsDrag ? 2.5 : tile.cursor ? 1 : 1.5 joinStyle: tile.iconRadius > 0 ? ShapePath.RoundJoin : ShapePath.MiterJoin PathSvg { path: tile.folderPath(tile.iconRadius) } } @@ -257,14 +259,14 @@ Item { return Qt.rect((w - 66) / 2, 0, 66, h) } Rectangle { - visible: tile.current || hover.hovered + visible: tile.current || tile.cursor || hover.hovered x: parent.shown.x - 3 y: parent.shown.y - 3 width: parent.shown.width + 6 height: parent.shown.height + 6 color: "transparent" border.width: tile.current ? 2 : 1 - border.color: tile.current ? theme.accent : Qt.alpha(theme.foreground, 0.2) + border.color: tile.current || tile.cursor ? theme.accent : Qt.alpha(theme.foreground, 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } @@ -283,12 +285,12 @@ Item { color: tile.current ? theme.accent : theme.foreground } Rectangle { - visible: tile.current || hover.hovered + visible: tile.current || tile.cursor || hover.hovered anchors.fill: computer anchors.margins: -3 color: "transparent" border.width: tile.current ? 2 : 1 - border.color: tile.current ? theme.accent : Qt.alpha(theme.foreground, 0.2) + border.color: tile.current || tile.cursor ? theme.accent : Qt.alpha(theme.foreground, 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } From c95f1852ff7dbf0d7684327cfea50538ab3b8ee1 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:36:54 -0400 Subject: [PATCH 04/20] Draw the other pane's selection without the accent In a split both panes drew their selection in the accent, so nothing said which pane the keys go to beyond the thin line at the top. The pane the keys don't go to now draws its selection and cursor in a neutral tint. --- src/FileRow.qml | 8 +++++--- src/FileTile.qml | 14 +++++++++----- src/FolderColumns.qml | 3 +++ src/FolderGrid.qml | 4 ++++ src/FolderList.qml | 4 ++++ src/FolderPane.qml | 6 +++--- 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/FileRow.qml b/src/FileRow.qml index 4e3ee3e6..ccb4cbf1 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -22,6 +22,8 @@ Item { required property bool selected property bool current: false + // In a pane the keys don't go to: the selection is drawn without the accent. + property bool inactive: false // In a column to the left: the folder the next column is showing. property bool trail: false property bool detailed: false @@ -64,9 +66,9 @@ Item { anchors.rightMargin: 4 radius: Math.min(4, theme.radius) border.width: folderDrop.containsDrag ? 2 : row.cursor ? 1 : 0 - border.color: theme.accent - color: row.current || folderDrop.containsDrag ? Qt.alpha(theme.accent, 0.18) - : row.trail ? Qt.alpha(theme.foreground, 0.08) + border.color: row.inactive && !folderDrop.containsDrag ? Qt.alpha(theme.foreground, 0.4) : theme.accent + color: row.current && !row.inactive || folderDrop.containsDrag ? Qt.alpha(theme.accent, 0.18) + : row.trail || row.current ? Qt.alpha(theme.foreground, 0.08) : hover.hovered ? Qt.alpha(theme.foreground, 0.05) : "transparent" } diff --git a/src/FileTile.qml b/src/FileTile.qml index 3222d992..f23c3161 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -25,6 +25,8 @@ Item { required property bool selected property bool current: false + // In a pane the keys don't go to: the selection is drawn without the accent. + property bool inactive: false signal clicked(int modifiers) signal activated() @@ -177,10 +179,12 @@ Item { height: 92 preferredRendererType: Shape.CurveRenderer ShapePath { - fillColor: tile.current || folderDrop.containsDrag ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.accent, 0.3)) + fillColor: tile.current && !tile.inactive || folderDrop.containsDrag ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.accent, 0.3)) + : tile.current ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.foreground, 0.08)) : hover.hovered ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.foreground, 0.06)) : theme.lighterBackground - strokeColor: tile.current || tile.cursor || folderDrop.containsDrag ? theme.accent : "transparent" + strokeColor: (tile.current || tile.cursor) && !tile.inactive || folderDrop.containsDrag ? theme.accent + : tile.current || tile.cursor ? Qt.alpha(theme.foreground, 0.4) : "transparent" strokeWidth: folderDrop.containsDrag ? 2.5 : tile.cursor ? 1 : 1.5 joinStyle: tile.iconRadius > 0 ? ShapePath.RoundJoin : ShapePath.MiterJoin PathSvg { path: tile.folderPath(tile.iconRadius) } @@ -266,7 +270,7 @@ Item { height: parent.shown.height + 6 color: "transparent" border.width: tile.current ? 2 : 1 - border.color: tile.current || tile.cursor ? theme.accent : Qt.alpha(theme.foreground, 0.2) + border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accent : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.4 : 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } @@ -282,7 +286,7 @@ Item { name: tile.icon size: 76 stroke: 1.2 - color: tile.current ? theme.accent : theme.foreground + color: tile.current && !tile.inactive ? theme.accent : theme.foreground } Rectangle { visible: tile.current || tile.cursor || hover.hovered @@ -290,7 +294,7 @@ Item { anchors.margins: -3 color: "transparent" border.width: tile.current ? 2 : 1 - border.color: tile.current || tile.cursor ? theme.accent : Qt.alpha(theme.foreground, 0.2) + border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accent : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.4 : 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } diff --git a/src/FolderColumns.qml b/src/FolderColumns.qml index 7e802fff..b4ec7667 100644 --- a/src/FolderColumns.qml +++ b/src/FolderColumns.qml @@ -10,6 +10,8 @@ Item { id: view property alias currentIndex: here.currentIndex + // In a pane the keys don't go to: the selection is drawn without the accent. + property bool inactive: false readonly property int columnWidth: 260 function select(row, keep) { selecting.select(row, keep) } @@ -173,6 +175,7 @@ Item { width: here.width chevron: true current: selected + inactive: view.inactive onClicked: (modifiers) => { selecting.click(index, modifiers); here.forceActiveFocus() } onActivated: backend.open(path) } diff --git a/src/FolderGrid.qml b/src/FolderGrid.qml index 37385b8a..c68e502b 100644 --- a/src/FolderGrid.qml +++ b/src/FolderGrid.qml @@ -5,6 +5,9 @@ import QtQuick.Controls GridView { id: grid + // In a pane the keys don't go to: the selection is drawn without the accent. + property bool inactive: false + function select(row, keep) { selecting.select(row, keep) } // A click on empty space lets go of the cursor; the pane then keeps it // let go of through a re-read or a switch of view. @@ -95,6 +98,7 @@ GridView { height: grid.cellHeight zoom: grid.tileZoom current: selected + inactive: grid.inactive onClicked: (modifiers) => { selecting.click(index, modifiers); grid.forceActiveFocus() } onActivated: backend.open(path) } diff --git a/src/FolderList.qml b/src/FolderList.qml index 8e93a633..c6dfaf7c 100644 --- a/src/FolderList.qml +++ b/src/FolderList.qml @@ -6,6 +6,9 @@ import QtQuick.Controls ListView { id: list + // In a pane the keys don't go to: the selection is drawn without the accent. + property bool inactive: false + function select(row, keep) { selecting.select(row, keep) } // A click on empty space lets go of the cursor; the pane then keeps it // let go of through a re-read or a switch of view. @@ -57,6 +60,7 @@ ListView { width: list.width detailed: true current: selected + inactive: list.inactive onClicked: (modifiers) => { selecting.click(index, modifiers); list.forceActiveFocus() } onActivated: backend.open(path) } diff --git a/src/FolderPane.qml b/src/FolderPane.qml index 1c3d5315..c151ad17 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -412,9 +412,9 @@ FocusScope { items: view.item } } - Component { id: gridView; FolderGrid { zoom: pane.zoom; onZoomRequested: (step) => pane.zoomBy(step) } } - Component { id: listView; FolderList { } } - Component { id: columnsView; FolderColumns { } } + Component { id: gridView; FolderGrid { zoom: pane.zoom; inactive: !pane.active; onZoomRequested: (step) => pane.zoomBy(step) } } + Component { id: listView; FolderList { inactive: !pane.active } } + Component { id: columnsView; FolderColumns { inactive: !pane.active } } } // Drawn over the window's top bar. It reaches back over the sidebar for From b3c8818db1428389239ef9aeacacb9ff8a6fe378 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:37:01 -0400 Subject: [PATCH 05/20] Wrap the details pane between words The details pane wrapped its title and text preview at any letter, so a name or a line of text broke mid-word. Both now break between words and split a word only when it is wider than the pane. --- src/PreviewPane.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 76e7aea0..4bcccca4 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -86,7 +86,7 @@ Rectangle { text: pane.info.text || "" color: theme.foreground font.pixelSize: 10 - wrapMode: Text.WrapAnywhere + wrapMode: Text.WrapAtWordBoundaryOrAnywhere textFormat: Text.PlainText elide: Text.ElideRight } @@ -114,7 +114,7 @@ Rectangle { color: theme.brightForeground font.pixelSize: 15 font.weight: Font.DemiBold - wrapMode: Text.WrapAnywhere + wrapMode: Text.WrapAtWordBoundaryOrAnywhere maximumLineCount: 3 elide: Text.ElideRight } From 708440900c2014a7e74cae4790478cb986120893 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:37:01 -0400 Subject: [PATCH 06/20] Keep the folder in view in the notice line The notice line elided its end, which is where a restore names the folder it put the file back in. It now elides the middle, so the start and the folder stay readable on a narrow pane. --- src/FolderPane.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FolderPane.qml b/src/FolderPane.qml index c151ad17..e701882e 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -346,7 +346,7 @@ FocusScope { Layout.fillWidth: true text: backend.message || backend.folder.error || (slowWork.shown ? slowWork.working : "") || backend.notice color: theme.brightForeground - elide: Text.ElideRight + elide: Text.ElideMiddle } // File work can be stopped where it's shown. A click leaves // the keyboard with the files, since the button goes once From a824473548fa77e9e3ac66daeffdbfefe91774dc Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:37:50 -0400 Subject: [PATCH 07/20] Say how many are selected in the details pane With several items selected, the details pane still showed only the one under the cursor, as if it were the whole selection. It now says how many are selected and their size together, or how many of them are folders. --- src/PreviewPane.qml | 13 +++++++++++-- src/foldermodel.cpp | 16 +++++++++++++++- src/foldermodel.h | 5 +++++ src/i18n/en.json | 8 ++++++++ tests/folio_tests.cpp | 28 ++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 4bcccca4..52197d16 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -15,6 +15,9 @@ Rectangle { // look for their trash notes or make large thumbnails for nobody. readonly property var item: visible && folderPane ? folderPane.current : ({}) readonly property bool hasItem: item !== undefined && item.path !== undefined + // With several selected, the title and the line under it are theirs. + readonly property var folder: folderPane ? folderPane.paneBackend.folder : null + readonly property int many: visible && folder && folder.selectedCount > 1 ? folder.selectedCount : 0 // An item in the trash shows where it came from and when it was // deleted, and offers Restore rather than Open. Its note is read in the // background; until it's in, the item looks trashed, as nearly all there @@ -39,6 +42,8 @@ Rectangle { readonly property bool hasKeyboard: opensWith.activeFocus function focusKeyboard() { opensWith.forceActiveFocus(Qt.TabFocusReason) } signal finished() + // Several selected in a plugin's view are its items, with no size to add up. + readonly property bool pluginItems: many > 0 && folderPane.inView function homeless(path) { return path.startsWith(places.home + "/") ? "~" + path.slice(places.home.length) : path } // A picture's size or a text's opening lines once they've been read off // the UI thread. @@ -110,7 +115,7 @@ Rectangle { Text { Layout.fillWidth: true horizontalAlignment: Text.AlignLeft - text: pane.item.name || "" + text: pane.many ? qsTr("%Ln items selected", "", pane.many) : pane.item.name || "" color: theme.brightForeground font.pixelSize: 15 font.weight: Font.DemiBold @@ -123,7 +128,10 @@ Rectangle { horizontalAlignment: Text.AlignLeft text: { translator.language // The type's name comes from the system, in the language of the moment. - return Format.summary(pane.item, backend.typeName(pane.item.mime || "")) + return !pane.many ? Format.summary(pane.item, backend.typeName(pane.item.mime || "")) + : pane.pluginItems ? "" + : pane.folder.selectedFolderCount > 0 ? qsTr("%Ln folders", "", pane.folder.selectedFolderCount) + : Format.size(pane.folder.selectedSize) } color: theme.secondaryForeground font.pixelSize: 12 @@ -133,6 +141,7 @@ Rectangle { GridLayout { Layout.fillWidth: true + visible: !pane.many columns: 2 rowSpacing: 12 columnSpacing: 16 diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 2cce5a3f..fc824b60 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -469,7 +469,8 @@ void FolderModel::reload() { for (const Entry &e : m_entries) if (m_selected.contains(e.path)) still.insert(e.path); - if (still != m_selected) { + // Kept items can still change size, which the totals follow. + if (still != m_selected || !same) { m_selected = still; emit selectionChanged(); } @@ -620,6 +621,19 @@ QStringList FolderModel::selectedPaths() const { return paths; } +qint64 FolderModel::selectedSize() const { + qint64 total = 0; + for (const Entry &e : m_entries) + if (!e.isDir && m_selected.contains(e.path)) + total += e.size; + return total; +} + +int FolderModel::selectedFolderCount() const { + return std::count_if(m_entries.begin(), m_entries.end(), + [this](const Entry &e) { return e.isDir && m_selected.contains(e.path); }); +} + int FolderModel::indexOf(const QString &name) const { for (int row = 0; row < m_entries.size(); ++row) if (m_entries[row].name == name) diff --git a/src/foldermodel.h b/src/foldermodel.h index 681a2c7e..408d53ac 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -30,6 +30,8 @@ class FolderModel : public QAbstractListModel { Q_PROPERTY(int selectedCount READ selectedCount NOTIFY selectionChanged) // Set, the folder lists what matches it here and in every folder below. Q_PROPERTY(QString query READ query WRITE setQuery NOTIFY queryChanged) + Q_PROPERTY(qint64 selectedSize READ selectedSize NOTIFY selectionChanged) + Q_PROPERTY(int selectedFolderCount READ selectedFolderCount NOTIFY selectionChanged) // The items a plugin's view lists, as Plugins::views gives them. Q_PROPERTY(QVariantList items READ items WRITE setItems NOTIFY itemsChanged) @@ -105,6 +107,9 @@ class FolderModel : public QAbstractListModel { Q_INVOKABLE void clearSelection(); Q_INVOKABLE bool isSelected(int row) const; Q_INVOKABLE QStringList selectedPaths() const; + // The selected files' bytes together, and how many selected are folders. + qint64 selectedSize() const; + int selectedFolderCount() const; // Synchronous building blocks, used by the background passes and tests. // A listing stops, empty, as soon as `stop` returns true. Names sort diff --git a/src/i18n/en.json b/src/i18n/en.json index 369dbda5..b9cd9a4f 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -32,10 +32,18 @@ "%1 · %2": "%1 · %2", "%1 × %2": "%1 × %2", "%1: %2": "%1: %2", + "%Ln folders": { + "one": "%Ln folder", + "other": "%Ln folders" + }, "%Ln items": { "one": "%Ln item", "other": "%Ln items" }, + "%Ln items selected": { + "one": "%Ln item selected", + "other": "%Ln items selected" + }, "%Ln items will be deleted permanently. This can't be undone.": { "one": "%Ln item will be deleted permanently. This can't be undone.", "other": "%Ln items will be deleted permanently. This can't be undone." diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 21e81d07..29e60367 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6059,6 +6059,34 @@ private slots: QCOMPARE(QLocale().toString(1.5, 'f', 1), QString("1,5")); } + void folderTotalsTheSelection() + { + QTemporaryDir dir; + QDir(dir.path()).mkdir("c"); + write(dir.filePath("a.txt"), "xx"); + write(dir.filePath("b.txt"), "xxxxx"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + + // Files add up their bytes; folders are only counted. + model.selectRange(1, 2, false); + QCOMPARE(model.selectedSize(), qint64(7)); + QCOMPARE(model.selectedFolderCount(), 0); + model.selectAll(); + QCOMPARE(model.selectedSize(), qint64(7)); + QCOMPARE(model.selectedFolderCount(), 1); + + // A selected file that grows is counted at its new size. + QSignalSpy selection(&model, &FolderModel::selectionChanged); + write(dir.filePath("a.txt"), "xxxx"); + model.reload(); + QVERIFY(loaded.wait(3000)); + QVERIFY(selection.count() > 0); + QCOMPARE(model.selectedSize(), qint64(9)); + } + private: // Packs `items`, from `folder`, into `archive`, its kind from its name. static void pack(const QString &archive, const QString &folder, const QStringList &items) From 2472b0ab020c072b2adad248a9d6fb3844e8e8f4 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:38:40 -0400 Subject: [PATCH 08/20] Name a search result by its file, with its folder beside it A search result was named by its whole path from the searched folder, so deep results led with folders and their own names were cut. Each now shows its file's name, with the folder it is in quieter beside or under it; a plugin's item keeps its own name. --- src/FileRow.qml | 13 ++++++- src/FileTile.qml | 8 +++- src/PreviewPane.qml | 12 +++++- src/foldermodel.cpp | 7 +++- src/foldermodel.h | 4 ++ tests/folio_tests.cpp | 89 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 128 insertions(+), 5 deletions(-) diff --git a/src/FileRow.qml b/src/FileRow.qml index ccb4cbf1..19d72aa2 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -20,6 +20,8 @@ Item { required property int itemCount required property string icon required property bool selected + required property string fileName + required property string folder property bool current: false // In a pane the keys don't go to: the selection is drawn without the accent. @@ -120,10 +122,19 @@ Item { Layout.fillWidth: true Layout.minimumWidth: 120 horizontalAlignment: Text.AlignLeft - text: row.name + text: row.fileName color: row.current ? theme.brightForeground : theme.foreground elide: Text.ElideMiddle } + Text { + visible: row.folder.length > 0 + Layout.maximumWidth: Math.round(row.width / 3) + horizontalAlignment: Text.AlignLeft + text: row.folder + color: theme.secondaryForeground + font.pixelSize: 12 + elide: Text.ElideMiddle + } Text { visible: row.showSize Layout.preferredWidth: 96 diff --git a/src/FileTile.qml b/src/FileTile.qml index f23c3161..e6d40e29 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -23,6 +23,8 @@ Item { required property var size required property var modified required property bool selected + required property string fileName + required property string folder property bool current: false // In a pane the keys don't go to: the selection is drawn without the accent. @@ -77,7 +79,7 @@ Item { anchors.topMargin: 12 anchors.horizontalCenter: parent.horizontalCenter width: parent.width - 28 - text: tile.name + text: tile.fileName color: tile.current ? theme.brightForeground : theme.foreground font.pixelSize: 12 horizontalAlignment: Text.AlignHCenter @@ -103,7 +105,9 @@ Item { // A folder's own date says little (it changes when anything in // it is added or removed), so only files show one. const date = tile.isDir ? "" : Format.when(tile.modified) - return size && date ? qsTr("%1 · %2").arg(size).arg(date) : size || date + const rest = size && date ? qsTr("%1 · %2").arg(size).arg(date) : size || date + // Both in one arg() call, so a "%2" in a folder's name stays as written. + return tile.folder && rest ? qsTr("%1 · %2").arg(tile.folder, rest) : tile.folder || rest } color: tile.current ? theme.foreground : theme.secondaryForeground font.pixelSize: 10 diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 52197d16..a7447b67 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -115,7 +115,7 @@ Rectangle { Text { Layout.fillWidth: true horizontalAlignment: Text.AlignLeft - text: pane.many ? qsTr("%Ln items selected", "", pane.many) : pane.item.name || "" + text: pane.many ? qsTr("%Ln items selected", "", pane.many) : pane.item.fileName || "" color: theme.brightForeground font.pixelSize: 15 font.weight: Font.DemiBold @@ -156,6 +156,16 @@ Rectangle { font.pixelSize: 12 elide: Text.ElideMiddle } + Text { visible: !!pane.item.folder; text: qsTr("Folder"); color: theme.secondaryForeground; font.pixelSize: 12 } + Text { + visible: !!pane.item.folder + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + text: pane.item.folder || "" + color: theme.foreground + font.pixelSize: 12 + elide: Text.ElideMiddle + } Text { visible: !pane.pluginItem; text: pane.trashed || pane.noting ? qsTr("Deleted") : qsTr("Modified"); color: theme.secondaryForeground; font.pixelSize: 12 } Text { visible: !pane.pluginItem diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index fc824b60..ed843f42 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -109,6 +109,8 @@ QVariant FolderModel::data(const QModelIndex &index, int role) const { case ItemCountRole: return e.itemCount; case SelectedRole: return m_selected.contains(e.path); case IconRole: return e.icon; + case FileNameRole: return e.folder.isEmpty() ? e.name : e.name.mid(e.folder.size() + 1); + case FolderRole: return e.folder; } return {}; } @@ -118,7 +120,7 @@ QHash FolderModel::roleNames() const { {NameRole, "name"}, {PathRole, "path"}, {IsDirRole, "isDir"}, {SizeRole, "size"}, {ModifiedRole, "modified"}, {KindRole, "kind"}, {MimeRole, "mime"}, {ExtensionRole, "extension"}, {ItemCountRole, "itemCount"}, {SelectedRole, "selected"}, - {IconRole, "icon"}, + {IconRole, "icon"}, {FileNameRole, "fileName"}, {FolderRole, "folder"}, }; } @@ -305,6 +307,8 @@ QVector FolderModel::search(const QString &root, const QStri if (info.fileName().contains(query, Qt::CaseInsensitive) && entries.size() < limit) { Entry e = entryFor(info, mimes); e.name = base.relativeFilePath(e.path); + if (const int slash = e.name.lastIndexOf('/'); slash > 0) + e.folder = e.name.left(slash); entries.push_back(e); } // Other filesystems (a mounted drive, another machine, /proc @@ -552,6 +556,7 @@ QVariantMap FolderModel::toMap(const Entry &entry) { {"modified", entry.modified}, {"kind", entry.kind}, {"mime", entry.mime}, {"extension", entry.extension}, {"itemCount", entry.itemCount}, {"icon", entry.icon}, {"details", entry.details}, + {"fileName", entry.folder.isEmpty() ? entry.name : entry.name.mid(entry.folder.size() + 1)}, {"folder", entry.folder}, }; } diff --git a/src/foldermodel.h b/src/foldermodel.h index 408d53ac..386cab1b 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -48,6 +48,8 @@ class FolderModel : public QAbstractListModel { ItemCountRole, SelectedRole, IconRole, + FileNameRole, + FolderRole, }; struct Entry { @@ -66,6 +68,8 @@ class FolderModel : public QAbstractListModel { int itemCount = -1; // The first pictures in a folder, for the preview window. QStringList peek; + // A search result's folder, from the searched one; empty when it is there. + QString folder; }; explicit FolderModel(QObject *parent = nullptr); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 29e60367..2c3b7538 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6087,6 +6087,95 @@ private slots: QCOMPARE(model.selectedSize(), qint64(9)); } + void folderSearchNamesTheFileAndItsFolder() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("Raw/Takes\\One"); + QDir(dir.path()).mkpath(QString::fromUtf8("A\u2215B/Deep/Down")); + write(dir.filePath("harbour.txt"), "x"); + write(dir.filePath("Raw/Takes\\One/harbour.mp4"), "x"); + write(dir.filePath(QString::fromUtf8("A\u2215B/Deep/Down/harbour.jpg")), "x"); + + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.get(0).value("folder").toString().isEmpty()); + model.setQuery("harbour"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 3); + + // The name stays the path from the searched folder; fileName and folder split it, no folder for one found here. + auto at = [&](const QString &name) { return model.get(model.indexOf(name)); }; + const QString deep = QString::fromUtf8("A\u2215B/Deep/Down/harbour.jpg"); + QCOMPARE(at(deep).value("fileName").toString(), QString("harbour.jpg")); + QCOMPARE(at(deep).value("folder").toString(), QString::fromUtf8("A\u2215B/Deep/Down")); + QCOMPARE(at("Raw/Takes\\One/harbour.mp4").value("fileName").toString(), QString("harbour.mp4")); + QCOMPARE(at("Raw/Takes\\One/harbour.mp4").value("folder").toString(), QString("Raw/Takes\\One")); + QCOMPARE(at("harbour.txt").value("fileName").toString(), QString("harbour.txt")); + QVERIFY(at("harbour.txt").value("folder").toString().isEmpty()); + const QModelIndex row = model.index(model.indexOf(deep)); + QCOMPARE(model.data(row, FolderModel::FileNameRole).toString(), QString("harbour.jpg")); + QCOMPARE(model.data(row, FolderModel::FolderRole).toString(), QString::fromUtf8("A\u2215B/Deep/Down")); + + // A plugin's item goes by its own name, even one with a slash in it. + const QVariantMap share{{"id", "nas"}, {"name", "nas/backup"}, {"path", "/mnt/nas/backup"}}; + const auto item = FolderModel::toMap(FolderModel::listItems({share}).at(0)); + QCOMPARE(item.value("fileName").toString(), QString("nas/backup")); + QVERIFY(item.value("folder").toString().isEmpty()); + } + + void searchResultTileKeepsAPercentInItsFolder() + { + // A "%2" in a result's folder stays as written, not taken for the size and date's place. + FileOps ops; + const std::unique_ptr theme(QQmlPropertyMap::create()); + const std::unique_ptr translator(QQmlPropertyMap::create()); + const std::unique_ptr backend(QQmlPropertyMap::create()); + const std::unique_ptr folder(QQmlPropertyMap::create()); + for (const char *role : {"accent", "foreground", "brightForeground", "secondaryForeground", "lighterBackground", + "darkBackground", "background", "red", "blue", "magenta", "orange", "yellow", "cyan"}) + theme->insert(role, QColor(Qt::gray)); + theme->insert("radius", 0); + translator->insert("language", "en"); + folder->insert("selectedCount", 0); + backend->insert("folder", QVariant::fromValue(folder.get())); + QQmlEngine engine; + engine.rootContext()->setContextProperty("fileOps", &ops); + engine.rootContext()->setContextProperty("theme", theme.get()); + engine.rootContext()->setContextProperty("translator", translator.get()); + engine.rootContext()->setContextProperty("backend", backend.get()); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window {\n" + " width: 200; height: 200; visible: true\n" + " GridView {\n" + " anchors.fill: parent; cellWidth: 200; cellHeight: 200; model: 1\n" + " delegate: FileTile {\n" + " width: 200; height: 200; name: 'a%2b/notes.bin'; fileName: 'notes.bin'; folder: 'a%2b'\n" + " path: '/nowhere/a%2b/notes.bin'; isDir: false; kind: 'file'; mime: 'application/octet-stream'\n" + " extension: 'bin'; itemCount: 0; icon: ''; size: 4096; modified: new Date(); selected: false\n" + " }\n" + " }\n" + "}\n", + QUrl::fromLocalFile(QFINDTESTDATA("../src/FileTile.qml") + ".test.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + QVERIFY(QTest::qWaitForWindowExposed(window)); + + QStringList texts; + std::function collect = [&](QQuickItem *item) { + if (item->isVisible() && !item->property("text").toString().isEmpty()) + texts << item->property("text").toString(); + for (QQuickItem *child : item->childItems()) + collect(child); + }; + collect(window->contentItem()); + const QString line = QString::fromUtf8("a%2b · ") + QLocale().toString(4.096, 'f', 1) + " KB"; + QVERIFY2(texts.contains(line), qPrintable(texts.join(" | "))); + } + private: // Packs `items`, from `folder`, into `archive`, its kind from its name. static void pack(const QString &archive, const QString &folder, const QStringList &items) From 760853fde88a1b74f1e4016eef2f3e0e24d6893e Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 11:06:31 -0400 Subject: [PATCH 09/20] Dim the whole window behind a dialog Rename, delete, mount, name clash and format dialogs were drawn inside their folder pane, so in a split, and beside the details pane, the rest stayed lit. They now dim the whole window, no pane takes keys while one is open, and the keyboard goes back to the pane once the last one closes. --- src/FolderPane.qml | 27 +++++++++++++++++++++------ src/FolioDialog.qml | 2 +- src/Main.qml | 5 +++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/FolderPane.qml b/src/FolderPane.qml index e701882e..d872a8bf 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -12,6 +12,7 @@ FocusScope { // The window this pane is in, for the state the panes share. readonly property var host: ApplicationWindow.window + readonly property Item dialogLayer: host ? host.dialogLayer : pane readonly property bool active: !!host && host.activePane === pane readonly property var paneBackend: backend readonly property var paneFileOps: fileOps @@ -50,7 +51,8 @@ FocusScope { property bool cursorLetGo: false // While a text field has the keyboard (renaming, a password), single-key // and clipboard shortcuts stand aside so typing works. - readonly property bool typing: rename.visible || confirm.visible || clash.visible || formatDialog.visible || backend.mounter.asking || pathBar.editing || pathBar.searchFocused || (!!host && host.helpVisible) + readonly property bool typing: dialogShowing || pathBar.editing || pathBar.searchFocused || (!!host && (host.dialogOpen || host.helpVisible)) + readonly property bool dialogShowing: rename.visible || confirm.visible || clash.visible || formatDialog.visible || backend.mounter.asking // Keys for the pane as a whole (undo, a new window, typing a path) work // wherever the keyboard is; keys for its files only while the files // have it, not while Tab has put it in the sidebar or the details, where @@ -122,6 +124,14 @@ FocusScope { // details pane follow. onActiveFocusChanged: if (activeFocus && host) host.activePane = pane function focusView() { if (view.item) view.item.focusView() } + // A dialog sits outside this scope, so it makes this pane active, and the last one to close hands the keys back. + function dialogShown(shown) { + if (shown && host) + host.activePane = pane + else if (!shown) + Qt.callLater(pane.handBack) + } + function handBack() { if (!(host && host.dialogOpen)) focusView() } // Pins a folder to the sidebar, or unpins it when it's pinned already. function togglePin(target) { @@ -456,31 +466,36 @@ FocusScope { // A dialog takes the keyboard while it's open; closing only hides it, // so the keyboard is handed back to the files. MountPrompt { + parent: pane.dialogLayer anchors.fill: parent - onVisibleChanged: if (!visible) pane.focusView() + onVisibleChanged: pane.dialogShown(visible) } RenameDialog { id: rename + parent: pane.dialogLayer anchors.fill: parent - onVisibleChanged: if (!visible) pane.focusView() + onVisibleChanged: pane.dialogShown(visible) } ConfirmDialog { id: confirm + parent: pane.dialogLayer anchors.fill: parent - onVisibleChanged: if (!visible) pane.focusView() + onVisibleChanged: pane.dialogShown(visible) } FormatDialog { id: formatDialog + parent: pane.dialogLayer anchors.fill: parent - onVisibleChanged: if (!visible) pane.focusView() + onVisibleChanged: pane.dialogShown(visible) } ClashDialog { id: clash + parent: pane.dialogLayer anchors.fill: parent - onVisibleChanged: if (!visible) pane.focusView() + onVisibleChanged: pane.dialogShown(visible) } } diff --git a/src/FolioDialog.qml b/src/FolioDialog.qml index 1093858e..1895041b 100644 --- a/src/FolioDialog.qml +++ b/src/FolioDialog.qml @@ -2,7 +2,7 @@ import QtQuick import QtQuick.Layouts // The frame Rename, Confirm and the mount prompt share: a scrim over the -// pane and a card in its middle, titled, holding the dialog's own fields +// window and a card in its middle, titled, holding the dialog's own fields // and buttons. A click on the scrim is the dialog's to answer; one on the // card stays there. Tab and Shift+Tab go round the dialog's own fields and // buttons, never on to the buttons behind it. diff --git a/src/Main.qml b/src/Main.qml index ff8f4221..6c56a8b8 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -70,6 +70,10 @@ ApplicationWindow { readonly property int detailsWidth: details.visible ? details.width : 0 // Whether the window is wide enough for the details column at all. readonly property bool detailsFit: width >= (split ? 1400 : 960) + // The panes draw their dialogs here, so they dim and centre on the whole window. + readonly property Item dialogLayer: layer + // A dialog dims the whole window, so while one is open no pane takes keys. + readonly property bool dialogOpen: leftPane.dialogShowing || (rightPane !== null && rightPane.dialogShowing) // The process works in the folder the window used last shows, in the // pane used last, for Omarchy's terminal key to open there. @@ -131,6 +135,7 @@ ApplicationWindow { } Item { + id: layer anchors.fill: parent enabled: win.settled opacity: win.settled ? 1 : 0 From 47d053aa4f404dd5a075c766e1be6a078bf0179c Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 12:37:38 -0400 Subject: [PATCH 10/20] Show the shortcut sheet's scroll bar at rest The fitted shortcut sheet's scroll bar showed only in use, so nothing said rows were below; it now stays shown in the card's margin. In the preview window the sheet takes the keyboard, so its keys scroll it, not the preview. --- src/FolioScrollBar.qml | 2 +- src/HelpOverlay.qml | 10 +++++- src/PreviewWindow.qml | 17 ++++++---- tests/folio_tests.cpp | 73 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/FolioScrollBar.qml b/src/FolioScrollBar.qml index f3c861fb..cec0a3d6 100644 --- a/src/FolioScrollBar.qml +++ b/src/FolioScrollBar.qml @@ -11,7 +11,7 @@ ScrollBar { implicitHeight: 4 radius: Math.min(2, theme.radius) color: Qt.alpha(theme.foreground, bar.pressed ? 0.5 : 0.28) - visible: bar.size < 1 && (bar.active || bar.hovered) + visible: bar.size < 1 && (bar.active || bar.hovered || bar.policy === ScrollBar.AlwaysOn) } background: null // Material fades the bar in and out of its active state; drop that. diff --git a/src/HelpOverlay.qml b/src/HelpOverlay.qml index 388be554..7bb64eb7 100644 --- a/src/HelpOverlay.qml +++ b/src/HelpOverlay.qml @@ -74,6 +74,7 @@ Rectangle { // Sized to what it holds: two columns of shortcuts side by side when // the window is wide enough, and scrolling when it's too short for them. Rectangle { + id: card anchors.centerIn: parent width: Math.min(parent.width - 64, content.implicitWidth + 56) height: Math.min(parent.height - 64, content.implicitHeight + 56) @@ -90,7 +91,14 @@ Rectangle { contentHeight: content.implicitHeight clip: true boundsBehavior: Flickable.StopAtBounds - ScrollBar.vertical: FolioScrollBar { } + // In the card's margin, clear of the descriptions; at rest too, so the rows below are known. + ScrollBar.vertical: FolioScrollBar { + policy: ScrollBar.AlwaysOn + parent: card + anchors.right: parent.right + y: flick.y + height: flick.height + } ColumnLayout { id: content diff --git a/src/PreviewWindow.qml b/src/PreviewWindow.qml index eb7530e3..cf493eab 100644 --- a/src/PreviewWindow.qml +++ b/src/PreviewWindow.qml @@ -33,6 +33,7 @@ ApplicationWindow { LayoutMirroring.childrenInherit: true property bool helpVisible: false + onHelpVisibleChanged: if (helpVisible) help.forceActiveFocus() readonly property bool rtl: Qt.application.layoutDirection === Qt.RightToLeft readonly property var item: previewer.item @@ -49,14 +50,15 @@ ApplicationWindow { Shortcut { sequences: ["Space", "Escape", "Q"]; context: Qt.ApplicationShortcut; onActivated: win.helpVisible ? win.helpVisible = false : Qt.quit() } Shortcut { sequence: "?"; context: Qt.ApplicationShortcut; onActivated: win.helpVisible = !win.helpVisible } - // The folder's files run right to left when mirrored, so Left is next. - Shortcut { sequence: "Left"; context: Qt.ApplicationShortcut; onActivated: win.rtl ? previewer.next() : previewer.previous() } - Shortcut { sequence: "Right"; context: Qt.ApplicationShortcut; onActivated: win.rtl ? previewer.previous() : previewer.next() } Shortcut { sequences: ["Return", "Enter"]; context: Qt.ApplicationShortcut; onActivated: previewer.open() } - Shortcut { sequences: ["Up"]; context: Qt.ApplicationShortcut; onActivated: win.call("scroll", -1) } - Shortcut { sequences: ["Down"]; context: Qt.ApplicationShortcut; onActivated: win.call("scroll", 1) } - Shortcut { sequences: [StandardKey.MoveToPreviousPage]; context: Qt.ApplicationShortcut; onActivated: win.call("page", -1) } - Shortcut { sequences: [StandardKey.MoveToNextPage]; context: Qt.ApplicationShortcut; onActivated: win.call("page", 1) } + // The folder's files run right to left when mirrored, so Left is next. + Shortcut { sequence: "Left"; context: Qt.ApplicationShortcut; enabled: !win.helpVisible; onActivated: win.rtl ? previewer.next() : previewer.previous() } + Shortcut { sequence: "Right"; context: Qt.ApplicationShortcut; enabled: !win.helpVisible; onActivated: win.rtl ? previewer.previous() : previewer.next() } + // While the shortcut sheet is up, these keys and the two above leave the preview behind it alone. + Shortcut { sequences: ["Up"]; context: Qt.ApplicationShortcut; enabled: !win.helpVisible; onActivated: win.call("scroll", -1) } + Shortcut { sequences: ["Down"]; context: Qt.ApplicationShortcut; enabled: !win.helpVisible; onActivated: win.call("scroll", 1) } + Shortcut { sequences: [StandardKey.MoveToPreviousPage]; context: Qt.ApplicationShortcut; enabled: !win.helpVisible; onActivated: win.call("page", -1) } + Shortcut { sequences: [StandardKey.MoveToNextPage]; context: Qt.ApplicationShortcut; enabled: !win.helpVisible; onActivated: win.call("page", 1) } Shortcut { sequences: [StandardKey.ZoomIn, "Ctrl+="]; context: Qt.ApplicationShortcut; onActivated: win.call("zoomBy", 1.25) } Shortcut { sequences: [StandardKey.ZoomOut]; context: Qt.ApplicationShortcut; onActivated: win.call("zoomBy", 0.8) } Shortcut { sequence: "Ctrl+0"; context: Qt.ApplicationShortcut; onActivated: win.call("fit") } @@ -178,6 +180,7 @@ ApplicationWindow { } HelpOverlay { + id: help anchors.fill: parent visible: win.helpVisible onDismissed: win.helpVisible = false diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 2c3b7538..f200f493 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -5793,6 +5793,79 @@ private slots: } } + void previewSheetTakesTheKeysFromThePreview() + { + // With the shortcut sheet up in the preview window, the keys scroll the sheet, not the file behind it, and its bar shows at rest. + QTemporaryDir home; + // Nothing here should write settings; any that did would land in the test's home, not the user's. + QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, home.path()); + QByteArray lines; + for (int i = 0; i < 400; ++i) + lines += "line " + QByteArray::number(i) + "\n"; + write(home.filePath("a.txt"), "a"); + write(home.filePath("b.txt"), lines); + write(home.filePath("c.txt"), "c"); + Theme theme(home.filePath("theme")); + Translator translator(home.filePath("language"), sourceFolder() + "/i18n"); + Previewer previewer; + previewer.start(home.filePath("b.txt"), false, "name"); + QTRY_COMPARE(previewer.count(), 3); + QCOMPARE(previewer.index(), 1); + QQmlEngine engine; + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("translator", &translator); + engine.rootContext()->setContextProperty("previewer", &previewer); + QQmlComponent component(&engine, QUrl::fromLocalFile(sourceFolder() + "/PreviewWindow.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + window->resize(480, 360); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window)); + + QObject *view = nullptr; + QTRY_VERIFY((view = root->property("view").value()) + && view->property("contentHeight").toDouble() > view->property("height").toDouble()); + QTest::keyClick(window, Qt::Key_Down); + const double read = view->property("contentY").toDouble(); + QVERIFY(read > 0); + + QObject *overlay = nullptr; + for (QObject *child : root->findChildren()) + if (QByteArray(child->metaObject()->className()).startsWith("HelpOverlay")) + overlay = child; + QVERIFY(overlay); + // The preview's own rows fit even its smallest window, so the sheet gets thirty to scroll through. + QVariantList rows; + for (int i = 0; i < 30; ++i) + rows << QVariant(QVariantList{QString("K%1").arg(i), QString("Row %1").arg(i)}); + overlay->setProperty("shortcuts", rows); + QQuickItem *sheet = nullptr, *bar = nullptr; + for (QQuickItem *child : overlay->findChildren()) { + if (child->inherits("QQuickFlickable")) + sheet = child; + if (child->inherits("QQuickScrollBar")) + bar = child; + } + QVERIFY(sheet && bar); + root->setProperty("helpVisible", true); + QTRY_VERIFY(bar->property("size").toDouble() > 0 && bar->property("size").toDouble() < 1); + + for (const Qt::Key key : {Qt::Key_Down, Qt::Key_PageDown, Qt::Key_Up, Qt::Key_Left, Qt::Key_Right}) + QTest::keyClick(window, key); + QVERIFY(sheet->property("contentY").toDouble() > 0); + QCOMPARE(previewer.index(), 1); + QCOMPARE(view->property("contentY").toDouble(), read); + auto *handle = bar->property("contentItem").value(); + QVERIFY(!bar->property("active").toBool() && !bar->property("hovered").toBool()); + QVERIFY(handle && handle->isVisible()); + + QTest::keyClick(window, Qt::Key_Escape); + QVERIFY(!root->property("helpVisible").toBool()); + QTest::keyClick(window, Qt::Key_Right); + QCOMPARE(previewer.index(), 2); + } + void themeFallsBack() { QTemporaryDir dir; From aca122fb27df0f49c35516fe0414be8dbdb29f79 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:40:45 -0400 Subject: [PATCH 11/20] Copy instead of move when Ctrl is held on a drop A drop on the same drive always moved, with no way to copy as most file managers do with Ctrl. Holding Ctrl at the drop now copies, and a drop that copies tells the source app so instead of saying it moved. --- src/FileRow.qml | 2 +- src/FileTile.qml | 2 +- src/FolderDrop.qml | 3 +-- src/Sidebar.qml | 4 ++-- src/fileops.cpp | 22 +++++++++++++++++++--- src/fileops.h | 11 +++++++---- tests/folio_tests.cpp | 37 +++++++++++++++++++++++++++++++++++++ 7 files changed, 68 insertions(+), 13 deletions(-) diff --git a/src/FileRow.qml b/src/FileRow.qml index 19d72aa2..257d1fd6 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -59,7 +59,7 @@ Item { enabled: row.isDir keys: ["text/uri-list"] onEntered: (drag) => { drag.accepted = fileOps.canDrop(drag.urls, row.path) } - onDropped: (drop) => { fileOps.dropInto(drop.urls, row.path); drop.accept(Qt.MoveAction) } + onDropped: (drop) => drop.accept(fileOps.dropInto(drop.urls, row.path, fileOps.ctrlHeld())) } Rectangle { diff --git a/src/FileTile.qml b/src/FileTile.qml index e6d40e29..6a26901c 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -124,7 +124,7 @@ Item { enabled: tile.isDir keys: ["text/uri-list"] onEntered: (drag) => { drag.accepted = fileOps.canDrop(drag.urls, tile.path) } - onDropped: (drop) => { fileOps.dropInto(drop.urls, tile.path); drop.accept(Qt.MoveAction) } + onDropped: (drop) => drop.accept(fileOps.dropInto(drop.urls, tile.path, fileOps.ctrlHeld())) } HoverHandler { diff --git a/src/FolderDrop.qml b/src/FolderDrop.qml index 4eb6d09d..b21b90a3 100644 --- a/src/FolderDrop.qml +++ b/src/FolderDrop.qml @@ -24,8 +24,7 @@ DropArea { onDropped: (drop) => { if (!takes(drop)) return - fileOps.dropInto(drop.urls, folder) - drop.accept(Qt.MoveAction) + drop.accept(fileOps.dropInto(drop.urls, folder, fileOps.ctrlHeld())) } Rectangle { diff --git a/src/Sidebar.qml b/src/Sidebar.qml index 9826bd11..debd53fc 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -198,10 +198,10 @@ Rectangle { onDropped: (drop) => { if (row.dropTrashes) { fileOps.trash(fileOps.localPaths(drop.urls)) + drop.accept(Qt.MoveAction) } else { - fileOps.dropInto(drop.urls, row.dropPath) + drop.accept(fileOps.dropInto(drop.urls, row.dropPath, fileOps.ctrlHeld())) } - drop.accept(Qt.MoveAction) } } contentItem: RowLayout { diff --git a/src/fileops.cpp b/src/fileops.cpp index da94370e..643eab0a 100644 --- a/src/fileops.cpp +++ b/src/fileops.cpp @@ -757,10 +757,18 @@ bool FileOps::canDrop(const QList &urls, const QString &folder) const { return any; } -void FileOps::dropInto(const QList &urls, const QString &folder) { +Qt::DropAction FileOps::dropInto(const QList &urls, const QString &folder, bool copy) { if (!canDrop(urls, folder) || !idle()) - return; - transferInBackground(localPaths(urls), folder, Drop, false); + return Qt::IgnoreAction; + // canDrop passed, so at least one item is from elsewhere. + const QStringList paths = fromElsewhere(urls, folder); + transferInBackground(paths, folder, copy ? Copy : Drop, false); + return copy || !sameDrive(paths.first(), folder) ? Qt::CopyAction : Qt::MoveAction; +} + +bool FileOps::ctrlHeld() const { + // Asked of the system, not the last event: the drop's own event doesn't say. + return QGuiApplication::queryKeyboardModifiers().testFlag(Qt::ControlModifier); } QString FileOps::uriList(const QStringList &paths) const { @@ -778,6 +786,14 @@ QStringList FileOps::localPaths(const QList &urls) const { return paths; } +QStringList FileOps::fromElsewhere(const QList &urls, const QString &folder) const { + QStringList paths; + for (const QString &path : localPaths(urls)) + if (!alreadyIn(path, folder)) + paths << path; + return paths; +} + template bool FileOps::inBackground(const std::function &label, Work work, Done done, Lane lane) { // Asked about first, work can find another pane's under way by now. diff --git a/src/fileops.h b/src/fileops.h index 9a87ecd8..68e30284 100644 --- a/src/fileops.h +++ b/src/fileops.h @@ -93,10 +93,10 @@ class FileOps : public QObject { Q_INVOKABLE void cut(const QStringList &paths); Q_INVOKABLE void yank(const QStringList &paths); Q_INVOKABLE void paste(const QString &folder); - // Files dropped on a folder: moved when they're on the same drive, - // copied from another drive. Dropping something where it already is - // does nothing. - Q_INVOKABLE void dropInto(const QList &urls, const QString &folder); + // Files dropped on a folder: each moved on its drive and copied from another, or all copied with `copy` (Ctrl held); returns CopyAction only when every item is copied, or IgnoreAction when it refuses. + Q_INVOKABLE Qt::DropAction dropInto(const QList &urls, const QString &folder, bool copy = false); + // Whether Ctrl is held right now, which makes a drop copy. + Q_INVOKABLE bool ctrlHeld() const; // Whether these can be dropped on `folder`: not onto themselves, and // not into the trash, which only Delete fills. Q_INVOKABLE bool canDrop(const QList &urls, const QString &folder) const; @@ -324,6 +324,9 @@ class FileOps : public QObject { QProcess *runTool(const QString &program, const QStringList &args, const QString &busy, const QString &missing, const QString &stopped, const std::function &finished); + // The local files among `urls` not already in `folder`: a copy of one already there would be a duplicate. + QStringList fromElsewhere(const QList &urls, const QString &folder) const; + QString m_busy; std::shared_ptr m_job; // The lane this pane's work under way is counted in. diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index f200f493..deb5d830 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6249,6 +6249,43 @@ private slots: QVERIFY2(texts.contains(line), qPrintable(texts.join(" | "))); } + void fileOpsDropCopiesWithCtrl() + { + QTemporaryDir from, to; + write(from.filePath("todo.txt"), "list"); + write(to.filePath("todo.txt"), "already here"); + write(to.filePath("notes.txt"), "kept"); + + // One drive, so only Ctrl copies: the source stays, the taken name is asked about, and what is already there is left. + FileOps ops; + QSignalSpy done(&ops, &FileOps::done); + QCOMPARE(ops.dropInto({QUrl::fromLocalFile(from.filePath("todo.txt")), QUrl::fromLocalFile(to.filePath("notes.txt"))}, to.path(), true), Qt::CopyAction); + QCOMPARE(ops.clash().value("name").toString(), QString("todo.txt")); + // A drop while that copy waits is refused, and says so rather than that it moved. + QCOMPARE(ops.dropInto({QUrl::fromLocalFile(from.filePath("todo.txt"))}, to.path(), false), Qt::IgnoreAction); + ops.answerClash("both", false); + QVERIFY(done.wait(3000)); + QCOMPARE(done.takeFirst().at(1).toString(), QString("todo (copy).txt")); + QVERIFY(QFile::exists(from.filePath("todo.txt"))); + QVERIFY(QFile::exists(to.filePath("todo (copy).txt"))); + QVERIFY(!QFile::exists(to.filePath("notes (copy).txt"))); + + // Without Ctrl it's still a move. + QCOMPARE(ops.dropInto({QUrl::fromLocalFile(from.filePath("todo.txt"))}, to.path(), false), Qt::MoveAction); + ops.answerClash("both", false); + QVERIFY(done.wait(3000)); + QVERIFY(!QFile::exists(from.filePath("todo.txt"))); + QVERIFY(QFile::exists(to.filePath("todo (copy 2).txt"))); + + // Through a link to the folder, what is already there is left out too. + write(from.filePath("plan.txt"), "next"); + QVERIFY(QFile::link(to.path(), from.filePath("there"))); + QCOMPARE(ops.dropInto({QUrl::fromLocalFile(from.filePath("plan.txt")), QUrl::fromLocalFile(to.filePath("notes.txt"))}, from.filePath("there"), true), Qt::CopyAction); + QVERIFY(done.wait(3000)); + QVERIFY(QFile::exists(to.filePath("plan.txt"))); + QVERIFY(!QFile::exists(to.filePath("notes (copy).txt"))); + } + private: // Packs `items`, from `folder`, into `archive`, its kind from its name. static void pack(const QString &archive, const QString &folder, const QStringList &items) From 54e0bbae5c0e85790ae4b523580bd0421a872676 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:49:02 -0400 Subject: [PATCH 12/20] Draw the shortcut sheet's resting scroll bar so it reads The sheet's scroll bar, shown at rest as the only sign of rows below, was 1.9:1 on its card. A bar shown at rest now takes the theme's quiet text color, 3:1 or more on every Omarchy theme; others are unchanged. --- src/FolioScrollBar.qml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/FolioScrollBar.qml b/src/FolioScrollBar.qml index cec0a3d6..488405b8 100644 --- a/src/FolioScrollBar.qml +++ b/src/FolioScrollBar.qml @@ -10,7 +10,9 @@ ScrollBar { implicitWidth: 4 implicitHeight: 4 radius: Math.min(2, theme.radius) - color: Qt.alpha(theme.foreground, bar.pressed ? 0.5 : 0.28) + // Shown at rest it is the only sign of more below, so it takes the theme's quiet text ink (3:1 at least). + color: bar.policy === ScrollBar.AlwaysOn ? (bar.pressed ? theme.foreground : theme.secondaryForeground) + : Qt.alpha(theme.foreground, bar.pressed ? 0.5 : 0.28) visible: bar.size < 1 && (bar.active || bar.hovered || bar.policy === ScrollBar.AlwaysOn) } background: null From 5c3a7a6229bde68e3c803492c7ae4d99c30d5769 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:49:23 -0400 Subject: [PATCH 13/20] Keep menus off the window edge and their open parent grey A menu could rest on the window's edge, and an open submenu's parent row wore the same accent as the row under the pointer. Menus now stop 8 px short of the edge, and the open parent keeps a neutral wash. --- src/FolioMenu.qml | 2 ++ src/FolioMenuItem.qml | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/FolioMenu.qml b/src/FolioMenu.qml index 1b4a4b70..4d461d37 100644 --- a/src/FolioMenu.qml +++ b/src/FolioMenu.qml @@ -9,6 +9,8 @@ Menu { exit: null topPadding: 4 bottomPadding: 4 + // Material's default of 0 lets a menu touch the window's edge. + margins: 8 // Submenu entries (Open With, Share) are made from this. delegate: FolioMenuItem { } // As wide as its widest entry shown, so a long label (in any language) diff --git a/src/FolioMenuItem.qml b/src/FolioMenuItem.qml index ad3824a2..4176ea49 100644 --- a/src/FolioMenuItem.qml +++ b/src/FolioMenuItem.qml @@ -35,7 +35,8 @@ MenuItem { x: 4 width: parent.width - 8 radius: Math.min(4, theme.radius) - color: entry.highlighted && entry.enabled ? Qt.alpha(theme.accent, 0.18) : "transparent" + color: entry.subMenu && entry.subMenu.visible ? Qt.alpha(theme.foreground, 0.08) + : entry.highlighted && entry.enabled ? Qt.alpha(theme.accent, 0.18) : "transparent" } contentItem: Item { From c53e1ca47407d2b97fd43f23581637a816201035 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:49:58 -0400 Subject: [PATCH 14/20] Put a plain space before AM and PM in dates Qt's en_US time puts a narrow no-break space before AM and PM, which the font draws as none, so times read "4:04PM". Format.js now swaps it for a plain space, and the details pane's date and time go through it too. --- src/Format.js | 12 +++++++++++- src/PreviewPane.qml | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/Format.js b/src/Format.js index a359f60e..740ffd94 100644 --- a/src/Format.js +++ b/src/Format.js @@ -30,7 +30,17 @@ function when(date) { const locale = Qt.locale() const now = new Date() const today = date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate() - return today ? date.toLocaleTimeString(locale, QtQml.Locale.ShortFormat) : date.toLocaleDateString(locale, QtQml.Locale.ShortFormat) + return spaced(today ? date.toLocaleTimeString(locale, QtQml.Locale.ShortFormat) : date.toLocaleDateString(locale, QtQml.Locale.ShortFormat)) +} + +// Its date and time, in the details. +function stamp(date) { + return date ? spaced(date.toLocaleString(Qt.locale(), QtQml.Locale.ShortFormat)) : "" +} + +// Qt puts a narrow no-break space (U+202F) before AM and PM, which draws as no space at all. +function spaced(text) { + return text.replace(/\u202f/g, " ") } // A folder's contents; nothing until they've been counted. diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index a7447b67..50578b83 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -174,7 +174,7 @@ Rectangle { text: { translator.language // Re-read when the language (and so the default locale) changes. const when = pane.trashed ? pane.info.deleted : pane.noting ? null : pane.item.modified - return pane.hasItem && when ? when.toLocaleString(Qt.locale(), Locale.ShortFormat) : "" + return pane.hasItem ? Format.stamp(when) : "" } color: theme.foreground font.pixelSize: 12 From b8451990e149a5f4383eb47994e2ab9e4403563f Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:50:22 -0400 Subject: [PATCH 15/20] Show no one file's picture when several are selected With several selected, the details pane drew one of them under "N items selected", and a selection with no cursor showed "Nothing selected". A selection is now counted with or without a cursor, under a plain glyph. --- src/PreviewPane.qml | 21 +++++++++------ tests/folio_tests.cpp | 59 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 50578b83..0b26c518 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -17,7 +17,10 @@ Rectangle { readonly property bool hasItem: item !== undefined && item.path !== undefined // With several selected, the title and the line under it are theirs. readonly property var folder: folderPane ? folderPane.paneBackend.folder : null - readonly property int many: visible && folder && folder.selectedCount > 1 ? folder.selectedCount : 0 + // A selection with no cursor (let go of by a click on empty space) is counted too. + readonly property int many: visible && folder && (folder.selectedCount > 1 || folder.selectedCount > 0 && !hasItem) ? folder.selectedCount : 0 + // The picture is one item's only while one is shown. + readonly property bool one: hasItem && !many // An item in the trash shows where it came from and when it was // deleted, and offers Restore rather than Open. Its note is read in the // background; until it's in, the item looks trashed, as nearly all there @@ -62,7 +65,7 @@ Rectangle { anchors.fill: parent anchors.margins: 28 spacing: 22 - visible: pane.hasItem + visible: pane.hasItem || pane.many > 0 Item { Layout.fillWidth: true @@ -73,14 +76,14 @@ Rectangle { anchors.fill: parent asynchronous: true fillMode: Image.PreserveAspectFit - source: pane.hasItem && !pane.item.isDir && pane.item.kind !== "text" && !pane.pluginItem ? Format.thumb(pane.item.path, pane.item.mime, 512, pane.item.modified) : "" + source: pane.one && !pane.item.isDir && pane.item.kind !== "text" && !pane.pluginItem ? Format.thumb(pane.item.path, pane.item.mime, 512, pane.item.modified) : "" visible: status === Image.Ready } // Text files show their opening lines instead of a picture. Rectangle { anchors.fill: parent - visible: pane.hasItem && pane.item.kind === "text" + visible: pane.one && pane.item.kind === "text" color: theme.background border.width: 1 border.color: Qt.alpha(theme.foreground, 0.12) @@ -99,11 +102,11 @@ Rectangle { Glyph { anchors.centerIn: parent - visible: !big.visible && pane.hasItem && pane.item.kind !== "text" - name: pane.pluginItem ? pane.item.icon : pane.hasItem && pane.item.isDir ? "folder" : "file" + visible: !big.visible && (pane.many > 0 || pane.hasItem && pane.item.kind !== "text") + name: pane.one && pane.pluginItem ? pane.item.icon : pane.one && pane.item.isDir ? "folder" : "file" size: 72 stroke: 1.2 - color: pane.hasItem && pane.item.isDir ? theme.accent : theme.secondaryForeground + color: pane.one && pane.item.isDir ? theme.accent : theme.secondaryForeground } } @@ -261,6 +264,8 @@ Rectangle { AbstractButton { id: openButton + // It acts on one item, so it stands aside for several. + visible: pane.one Layout.fillWidth: true implicitHeight: 40 focusPolicy: Qt.NoFocus @@ -291,7 +296,7 @@ Rectangle { Text { anchors.centerIn: parent - visible: !pane.hasItem + visible: !pane.hasItem && !pane.many text: qsTr("Nothing selected") color: theme.secondaryForeground } diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index deb5d830..e3228b97 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -4329,6 +4329,65 @@ private slots: QCOMPARE(backend.path(), QDir::cleanPath(home.path())); } + void detailsDrawAPlainGlyphForSeveralPluginItems() + { + // One of a plugin's items shows its own icon in the details; several selected show the plain glyph, as several files do. + QTemporaryDir folder, home; + writePlugin(folder.path(), "servers", R"({"folio": 1, "poll": {"command": ["./servers"]}, + "view": {"title": "Servers", "label": "All", "icon": "drive"}})", + {{"servers", "#!/bin/sh\ncat <("Folio", 1, 0, "FolderModel"); + engine.rootContext()->setContextProperty("translator", &translator); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("places", &places); + engine.rootContext()->setContextProperty("plugins", &plugins); + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("fileOps", &ops); + QQmlComponent component(&engine, QUrl::fromLocalFile(sourceFolder() + "/Main.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + backend.navigate("plugin:servers"); + QTRY_COMPARE(backend.folder()->count(), 2); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window)); + + // The details' large glyph: the one of its size inside the pane. + const int detailsGlyph = 72; + std::function find = [&](QQuickItem *item, bool inPane) -> QQuickItem * { + const QByteArray type = item->metaObject()->className(); + if (inPane && type.startsWith("Glyph") && item->property("size").toInt() == detailsGlyph) + return item; + for (QQuickItem *child : item->childItems()) + if (QQuickItem *found = find(child, inPane || type.startsWith("PreviewPane"))) + return found; + return nullptr; + }; + QQuickItem *glyph = find(window->contentItem(), false); + QVERIFY(glyph); + + // The window takes no keys until it has settled. + QTRY_VERIFY(window->property("settled").toBool()); + QTest::keyClick(window, Qt::Key_Down); + QTRY_COMPARE(glyph->property("name").toString(), QString("peer")); + backend.folder()->selectAll(); + QCOMPARE(backend.folder()->selectedCount(), 2); + QTRY_COMPARE(glyph->property("name").toString(), QString("file")); + } + void pluginPollsArentHeldUpByActions() { QTemporaryDir folder, home; From 4af40ec36612bae7155950f31322778f3fcfe3cc Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:50:30 -0400 Subject: [PATCH 16/20] Leave the date off a search result tile led by its folder A search result tile's line led with its folder, then size and date, so the date was cut mid-number on a 140 px tile. When the folder leads, the line holds folder and size; the details pane still gives the date. --- src/FileTile.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FileTile.qml b/src/FileTile.qml index 6a26901c..ccbdf875 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -104,7 +104,7 @@ Item { const size = tile.isDir ? Format.count(tile.itemCount) : Format.size(tile.size) // A folder's own date says little (it changes when anything in // it is added or removed), so only files show one. - const date = tile.isDir ? "" : Format.when(tile.modified) + const date = tile.isDir || tile.folder ? "" : Format.when(tile.modified) // A search result's folder takes the date's room. const rest = size && date ? qsTr("%1 · %2").arg(size).arg(date) : size || date // Both in one arg() call, so a "%2" in a folder's name stays as written. return tile.folder && rest ? qsTr("%1 · %2").arg(tile.folder, rest) : tile.folder || rest From 3761edfeb6284bb3add4cc4243ad342a8d69edb6 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 10:56:26 -0400 Subject: [PATCH 17/20] Say whether a drop will move or copy while it hovers A drop could move or copy, but its frame said neither, so the verb was known only after letting go. Each drop target, columns included, now says "Move here" or "Copy here", from Ctrl and whether every file is on another drive. --- src/FileDrop.qml | 19 ++++++ src/FileRow.qml | 15 +++-- src/FileTile.qml | 6 +- src/FolderDrop.qml | 12 +++- src/Sidebar.qml | 5 +- src/fileops.cpp | 23 ++++++- src/fileops.h | 2 + src/i18n/en.json | 2 + src/resources.qrc | 1 + tests/folio_tests.cpp | 148 ++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 src/FileDrop.qml diff --git a/src/FileDrop.qml b/src/FileDrop.qml new file mode 100644 index 00000000..df553688 --- /dev/null +++ b/src/FileDrop.qml @@ -0,0 +1,19 @@ +import QtQuick + +// A folder files can be dropped on. While they hover it knows whether letting go moves or copies them. +DropArea { + id: area + property string folder + property bool copies: false + // Whether the hovering files all come from other drives. It asks the drives, so it is read once a drag, never on the timer. + property bool otherDrive: false + // The drag loop takes the key events, so Ctrl pressed or let go with the pointer still is seen only by asking again. + readonly property int ctrlCheckMs: 100 + + keys: ["text/uri-list"] + function recheck() { copies = otherDrive || fileOps.ctrlHeld() } + onEntered: (drag) => { otherDrive = fileOps.dropCopies(drag.urls, folder, false); recheck() } + onPositionChanged: recheck() + + Timer { interval: area.ctrlCheckMs; repeat: true; running: area.containsDrag; onTriggered: area.recheck() } +} diff --git a/src/FileRow.qml b/src/FileRow.qml index 257d1fd6..13fe728d 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -53,11 +53,11 @@ Item { : "file" // A folder row takes files dropped on it, lighting up while they hover. - DropArea { + FileDrop { id: folderDrop anchors.fill: parent enabled: row.isDir - keys: ["text/uri-list"] + folder: row.path onEntered: (drag) => { drag.accepted = fileOps.canDrop(drag.urls, row.path) } onDropped: (drop) => drop.accept(fileOps.dropInto(drop.urls, row.path, fileOps.ctrlHeld())) } @@ -136,7 +136,7 @@ Item { elide: Text.ElideMiddle } Text { - visible: row.showSize + visible: row.showSize && !folderDrop.containsDrag Layout.preferredWidth: 96 horizontalAlignment: Text.AlignRight text: row.kind === "item" ? row.extension : row.isDir ? Format.count(row.itemCount) : Format.size(row.size) @@ -144,7 +144,7 @@ Item { font.pixelSize: 12 } Text { - visible: row.showDate + visible: row.showDate && !folderDrop.containsDrag Layout.preferredWidth: 96 Layout.leftMargin: 12 horizontalAlignment: Text.AlignLeft @@ -156,6 +156,13 @@ Item { font.pixelSize: 12 elide: Text.ElideRight } + // While files hover a folder, what the drop will do takes the size and date's place. + Text { + visible: folderDrop.containsDrag + text: folderDrop.copies ? qsTr("Copy here") : qsTr("Move here") + color: theme.secondaryForeground + font.pixelSize: 12 + } // Points to where the next column opens. Glyph { visible: row.chevron && row.isDir diff --git a/src/FileTile.qml b/src/FileTile.qml index ccbdf875..840e9b15 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -101,6 +101,8 @@ Item { translator.language // Re-read when the language (and so the default locale) changes. if (tile.kind === "item") return tile.extension + if (folderDrop.containsDrag) + return folderDrop.copies ? qsTr("Copy here") : qsTr("Move here") const size = tile.isDir ? Format.count(tile.itemCount) : Format.size(tile.size) // A folder's own date says little (it changes when anything in // it is added or removed), so only files show one. @@ -118,11 +120,11 @@ Item { // A folder takes files dropped anywhere on its tile, picture or name, // lighting up while they hover: the view's own drop area refuses the // whole cell, so the tile has to take all of it. - DropArea { + FileDrop { id: folderDrop anchors.fill: parent enabled: tile.isDir - keys: ["text/uri-list"] + folder: tile.path onEntered: (drag) => { drag.accepted = fileOps.canDrop(drag.urls, tile.path) } onDropped: (drop) => drop.accept(fileOps.dropInto(drop.urls, tile.path, fileOps.ctrlHeld())) } diff --git a/src/FolderDrop.qml b/src/FolderDrop.qml index b21b90a3..fcea8af5 100644 --- a/src/FolderDrop.qml +++ b/src/FolderDrop.qml @@ -5,10 +5,9 @@ import QtQuick // where it refuses one (it's the folder being dropped, or the one the drop // is already in), this refuses too, rather than catching the drop as it // falls through and landing it in this folder instead. -DropArea { +FileDrop { id: area - property string folder // The list or grid whose items lie over this area. property Item items @@ -18,7 +17,6 @@ DropArea { return !(item && item.isDir) && fileOps.canDrop(drag.urls, folder) } - keys: ["text/uri-list"] onEntered: (drag) => { drag.accepted = takes(drag) } onPositionChanged: (drag) => { drag.accepted = takes(drag) } onDropped: (drop) => { @@ -35,5 +33,13 @@ DropArea { border.width: 2 border.color: theme.accent radius: Math.min(6, theme.radius) + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 12 + text: area.copies ? qsTr("Copy here") : qsTr("Move here") + color: theme.secondaryForeground + font.pixelSize: 12 + } } } diff --git a/src/Sidebar.qml b/src/Sidebar.qml index debd53fc..732b9ee6 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -189,11 +189,11 @@ Rectangle { border.color: theme.accent } - DropArea { + FileDrop { id: rowDrop anchors.fill: parent enabled: row.dropPath.length > 0 || row.dropTrashes - keys: ["text/uri-list"] + folder: row.dropPath onEntered: (drag) => { drag.accepted = row.dropTrashes ? drag.hasUrls : fileOps.canDrop(drag.urls, row.dropPath) } onDropped: (drop) => { if (row.dropTrashes) { @@ -243,6 +243,7 @@ Rectangle { : row.mark.tone === "warning" ? theme.yellow : theme.secondaryForeground font.pixelSize: 11 } + Text { visible: rowDrop.containsDrag && !row.dropTrashes; text: rowDrop.copies ? qsTr("Copy here") : qsTr("Move here"); color: theme.secondaryForeground; font.pixelSize: 11 } } } diff --git a/src/fileops.cpp b/src/fileops.cpp index 643eab0a..9128e890 100644 --- a/src/fileops.cpp +++ b/src/fileops.cpp @@ -762,8 +762,10 @@ Qt::DropAction FileOps::dropInto(const QList &urls, const QString &folder, return Qt::IgnoreAction; // canDrop passed, so at least one item is from elsewhere. const QStringList paths = fromElsewhere(urls, folder); + // Asked before the work starts, which can move items away. + const Qt::DropAction action = dropCopies(urls, folder, copy) ? Qt::CopyAction : Qt::MoveAction; transferInBackground(paths, folder, copy ? Copy : Drop, false); - return copy || !sameDrive(paths.first(), folder) ? Qt::CopyAction : Qt::MoveAction; + return action; } bool FileOps::ctrlHeld() const { @@ -771,6 +773,25 @@ bool FileOps::ctrlHeld() const { return QGuiApplication::queryKeyboardModifiers().testFlag(Qt::ControlModifier); } +bool FileOps::dropCopies(const QList &urls, const QString &folder, bool copy) const { + if (copy) + return true; + const QStringList paths = fromElsewhere(urls, folder); + // One item on the folder's drive makes it a move; a folder may be a mount so asks itself, a file or link asks its folder once. + const QByteArray drive = QStorageInfo(folder).device(); + QSet asked; + for (const QString &path : paths) { + const QFileInfo item(path); + const QString checked = item.isDir() && !item.isSymLink() ? path : item.absolutePath(); + if (asked.contains(checked)) + continue; + asked.insert(checked); + if (QStorageInfo(checked).device() == drive) + return false; + } + return !paths.isEmpty(); +} + QString FileOps::uriList(const QStringList &paths) const { QStringList uris; for (const QString &path : paths) diff --git a/src/fileops.h b/src/fileops.h index 68e30284..b0c53b2f 100644 --- a/src/fileops.h +++ b/src/fileops.h @@ -97,6 +97,8 @@ class FileOps : public QObject { Q_INVOKABLE Qt::DropAction dropInto(const QList &urls, const QString &folder, bool copy = false); // Whether Ctrl is held right now, which makes a drop copy. Q_INVOKABLE bool ctrlHeld() const; + // Whether a drop of these on `folder` copies every item: with `copy` (Ctrl held), or all from other drives. + Q_INVOKABLE bool dropCopies(const QList &urls, const QString &folder, bool copy) const; // Whether these can be dropped on `folder`: not onto themselves, and // not into the trash, which only Delete fills. Q_INVOKABLE bool canDrop(const QList &urls, const QString &folder) const; diff --git a/src/i18n/en.json b/src/i18n/en.json index b9cd9a4f..550544ce 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -88,6 +88,7 @@ "Copied %1, but couldn't remove the original.": "Copied %1, but couldn't remove the original.", "Copy": "Copy", "Copy Path": "Copy Path", + "Copy here": "Copy here", "Copy the path as text": "Copy the path as text", "Copy, cut, paste files": "Copy, cut, paste files", "Copying %1: %2 of %3…": "Copying %1: %2 of %3…", @@ -228,6 +229,7 @@ "Modified": "Modified", "Mount": "Mount", "Move between the sidebar, the panes and the details": "Move between the sidebar, the panes and the details", + "Move here": "Move here", "Move the selection": "Move the selection", "Move to the trash": "Move to the trash", "Moved only part of %1: %2": "Moved only part of %1: %2", diff --git a/src/resources.qrc b/src/resources.qrc index a4118dfc..747fc975 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -34,6 +34,7 @@ PreviewWindow.qml PdfPreview.qml MediaPreview.qml + FileDrop.qml Format.js i18n/en.json i18n/ar.json diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index e3228b97..669adee3 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1377,6 +1377,7 @@ private slots: const std::unique_ptr theme(QQmlPropertyMap::create()); theme->insert("accent", QColor(Qt::blue)); theme->insert("radius", 0); + theme->insert("secondaryForeground", QColor(Qt::gray)); QQmlEngine engine; engine.rootContext()->setContextProperty("fileOps", &ops); engine.rootContext()->setContextProperty("theme", theme.get()); @@ -1422,6 +1423,101 @@ private slots: QVERIFY(QFileInfo(home.filePath("install")).isDir()); } + void dropsInAColumnAndOnATilesNameCopyWithCtrl() + { + // A column's drop area and a folder tile's name both say "Copy here" with Ctrl held, and copy. + QTemporaryDir home; + QDir(home.path()).mkpath("column"); + QDir(home.path()).mkpath("project"); + write(home.filePath("a.txt"), "a"); + write(home.filePath("b.txt"), "b"); + FileOps ops; + const std::unique_ptr theme(QQmlPropertyMap::create()); + const std::unique_ptr translator(QQmlPropertyMap::create()); + const std::unique_ptr backend(QQmlPropertyMap::create()); + const std::unique_ptr folder(QQmlPropertyMap::create()); + for (const char *role : {"accent", "foreground", "brightForeground", "secondaryForeground", "lighterBackground", + "darkBackground", "red", "blue", "magenta", "orange", "yellow", "cyan"}) + theme->insert(role, QColor(Qt::gray)); + theme->insert("radius", 0); + translator->insert("language", "en"); + folder->insert("selectedCount", 0); + backend->insert("folder", QVariant::fromValue(folder.get())); + QQmlEngine engine; + engine.rootContext()->setContextProperty("fileOps", &ops); + engine.rootContext()->setContextProperty("theme", theme.get()); + engine.rootContext()->setContextProperty("translator", translator.get()); + engine.rootContext()->setContextProperty("backend", backend.get()); + engine.rootContext()->setContextProperty("home", home.path()); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window {\n" + " width: 400; height: 300; visible: true\n" + " ListView {\n" + " id: column; width: 200; height: 300; model: 1\n" + " delegate: Item { width: 200; height: 40 }\n" + " }\n" + " FolderDrop { parent: column; anchors.fill: parent; z: -1; items: column; folder: home + '/column' }\n" + " GridView {\n" + " x: 200; width: 200; height: 300; cellWidth: 200; cellHeight: 200; model: 1\n" + " delegate: FileTile {\n" + " width: 200; height: 200; name: 'project'; fileName: 'project'; folder: ''\n" + " path: home + '/project'; isDir: true; kind: 'folder'; mime: 'inode/directory'\n" + " extension: ''; itemCount: 0; icon: ''; size: 0; modified: new Date(); selected: false\n" + " }\n" + " }\n" + "}\n", + QUrl::fromLocalFile(QFINDTESTDATA("../src/FolderDrop.qml") + ".test.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + QVERIFY(QTest::qWaitForWindowExposed(window)); + + // Whether a shown text anywhere in the window reads `text`. + std::function shows = [&](QQuickItem *item, const QString &text) { + if (item->isVisible() && item->property("text").toString() == text) + return true; + for (QQuickItem *child : item->childItems()) + if (shows(child, text)) + return true; + return false; + }; + QMimeData data; + // Enters at `at`, checks the verb shown, then drops, giving what the drop did. + const auto dropAt = [&](const QString &file, QPointF at, const QString &verb) { + data.setUrls({QUrl::fromLocalFile(home.filePath(file))}); + QDragEnterEvent enter(at, Qt::CopyAction | Qt::MoveAction, &data, Qt::LeftButton, Qt::NoModifier); + QGuiApplication::sendEvent(window, &enter); + const bool said = shows(window->contentItem(), verb); + QDropEvent drop(at, Qt::CopyAction | Qt::MoveAction, &data, Qt::LeftButton, Qt::NoModifier); + QGuiApplication::sendEvent(window, &drop); + return said && drop.isAccepted() ? drop.dropAction() : Qt::IgnoreAction; + }; + QSignalSpy done(&ops, &FileOps::done); + QTest::keyPress(window, Qt::Key_Control, Qt::ControlModifier); + auto ctrl = qScopeGuard([&] { QTest::keyRelease(window, Qt::Key_Control, Qt::NoModifier); }); + + // Below the column's last item. + QCOMPARE(dropAt("a.txt", {100, 150}, "Copy here"), Qt::CopyAction); + QVERIFY(done.wait(3000)); + QVERIFY(QFile::exists(home.filePath("column/a.txt"))); + QVERIFY(QFile::exists(home.filePath("a.txt"))); + + // On the tile's name, under its picture. + QCOMPARE(dropAt("a.txt", {300, 128}, "Copy here"), Qt::CopyAction); + QVERIFY(done.wait(3000)); + QVERIFY(QFile::exists(home.filePath("project/a.txt"))); + QVERIFY(QFile::exists(home.filePath("a.txt"))); + + // Ctrl let go, the name takes a move. + ctrl.dismiss(); + QTest::keyRelease(window, Qt::Key_Control, Qt::NoModifier); + QCOMPARE(dropAt("b.txt", {300, 128}, "Move here"), Qt::MoveAction); + QVERIFY(done.wait(3000)); + QVERIFY(QFile::exists(home.filePath("project/b.txt"))); + QVERIFY(!QFile::exists(home.filePath("b.txt"))); + } + void fileOpsDragUriLists() { // Names holding what means something in a URI ("#", "?", "%") are @@ -6345,6 +6441,58 @@ private slots: QVERIFY(!QFile::exists(to.filePath("notes (copy).txt"))); } + void fileOpsSaysWhetherADropCopies() + { + QTemporaryDir from, to; + write(from.filePath("todo.txt"), "list"); + const QList urls{QUrl::fromLocalFile(from.filePath("todo.txt"))}; + + // On one drive only Ctrl copies; nothing local to drop never does. + FileOps ops; + QVERIFY(!ops.dropCopies(urls, to.path(), false)); + QVERIFY(ops.dropCopies(urls, to.path(), true)); + QVERIFY(!ops.dropCopies({QUrl("https://example.com/todo.txt")}, to.path(), false)); + + // From another drive it copies without Ctrl. + if (!FileOps::sameDrive(from.filePath("todo.txt"), "/proc")) + QVERIFY(ops.dropCopies(urls, "/proc", false)); + + // What is already there stays out of it, as the drop leaves it, so the rest decide. + write(to.filePath("notes.txt"), "kept"); + if (!FileOps::sameDrive(to.path(), "/proc")) + QVERIFY(ops.dropCopies({QUrl::fromLocalFile(to.filePath("notes.txt")), QUrl::fromLocalFile("/proc/version")}, to.path(), false)); + + // One from the folder's own drive moves, so the drop says so even when another drive's item leads. + if (!FileOps::sameDrive(from.path(), "/proc")) + QVERIFY(!ops.dropCopies({QUrl::fromLocalFile("/proc/version"), urls.first()}, to.path(), false)); + + // A mounted folder is asked itself, as the work asks it, never by the folder it is in; a link by its own folder. + QCOMPARE(ops.dropCopies({QUrl::fromLocalFile("/proc")}, to.path(), false), !FileOps::sameDrive("/proc", to.path())); + QVERIFY(QFile::link("/proc", from.filePath("proc"))); + QCOMPARE(ops.dropCopies({QUrl::fromLocalFile(from.filePath("proc"))}, to.path(), false), + !FileOps::sameDrive(from.filePath("proc"), to.path())); + } + + void fileOpsMixedDropMovesWhatItSaysItMoves() + { + // A drop from two drives, led by the other drive's file, says Move: the one already on this drive is moved. + QTemporaryDir shm("/dev/shm/folio-XXXXXX"), from, to; + QVERIFY(shm.isValid()); + write(shm.filePath("far.txt"), "far"); + write(from.filePath("near.txt"), "near"); + if (FileOps::sameDrive(shm.path(), to.path())) + QSKIP("Needs a second drive."); + FileOps ops; + QSignalSpy done(&ops, &FileOps::done); + QCOMPARE(ops.dropInto({QUrl::fromLocalFile(shm.filePath("far.txt")), QUrl::fromLocalFile(from.filePath("near.txt"))}, to.path()), + Qt::MoveAction); + QVERIFY(done.wait(3000)); + QVERIFY(QFile::exists(to.filePath("far.txt"))); + QVERIFY(QFile::exists(to.filePath("near.txt"))); + QVERIFY(QFile::exists(shm.filePath("far.txt"))); + QVERIFY(!QFile::exists(from.filePath("near.txt"))); + } + private: // Packs `items`, from `folder`, into `archive`, its kind from its name. static void pack(const QString &archive, const QString &folder, const QStringList &items) From 0ce1d6c1191b06798f174c15052015bd66b89d19 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:53:13 -0400 Subject: [PATCH 18/20] Make the pane a right click lands in the active one A right click on a file in the other pane selected it there, but the keys stayed with the first pane, so Delete and F2 after the menu acted on the wrong file. A right click now gives the keys to its pane first. --- src/FolderPane.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/FolderPane.qml b/src/FolderPane.qml index d872a8bf..1240d5a7 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -231,7 +231,8 @@ FocusScope { Connections { target: view.item - function onContextMenu(item, folder, at) { fileMenu.show(item, folder, null, at) } + // A right click in the other pane makes it the one the keys and the menu act on, as a left click does. + function onContextMenu(item, folder, at) { pane.focusView(); fileMenu.show(item, folder, null, at) } function onCursorLetGo() { pane.cursorLetGo = true } // The cursor's item is remembered as the cursor moves, and once the // folder's loaded has placed it. Not when a re-read leaves the cursor From 4089e5b63a0c4607ac013e1e7f6469ded8892b44 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:53:37 -0400 Subject: [PATCH 19/20] Line up "Nothing selected" with the empty folder's caption "Nothing selected" sat 8 px above "This folder is empty" beside it, since the folder view starts 16 px lower than the details pane. It now drops by half that, so the two read as one line. --- src/PreviewPane.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 0b26c518..6a757e47 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -296,6 +296,8 @@ Rectangle { Text { anchors.centerIn: parent + // On the empty folder's line beside it: that view starts 16 px lower, under a gap below the bar. + anchors.verticalCenterOffset: 8 visible: !pane.hasItem && !pane.many text: qsTr("Nothing selected") color: theme.secondaryForeground From 8ad8fc7d32b2b506dd8046ac10c3bd4ccd14a51d Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:54:00 -0400 Subject: [PATCH 20/20] Drop the selection's accent while the keys are elsewhere While the caret was in the search field, or Tab had put the keyboard in the sidebar or the details, the selection wore the full accent ring, as if the keys were on it. The view now draws it as the other pane's until the keys come back to the files. --- src/FolderPane.qml | 8 ++++--- src/Main.qml | 2 ++ tests/folio_tests.cpp | 50 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/FolderPane.qml b/src/FolderPane.qml index 1240d5a7..37f79a83 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -59,6 +59,8 @@ FocusScope { // Delete or Space would act on files out of the keyboard's sight. readonly property bool paneKeys: active && !typing readonly property bool keys: paneKeys && activeFocus + // With the keys in another pane, a search field, the sidebar or the details, the views draw their selection without the accent. + readonly property bool keysElsewhere: !active || pathBar.searchFocused || (!!host && host.keysBesidePanes) // What file actions act on: everything selected, or the item under // the cursor when nothing is. function selectedPaths() { @@ -423,9 +425,9 @@ FocusScope { items: view.item } } - Component { id: gridView; FolderGrid { zoom: pane.zoom; inactive: !pane.active; onZoomRequested: (step) => pane.zoomBy(step) } } - Component { id: listView; FolderList { inactive: !pane.active } } - Component { id: columnsView; FolderColumns { inactive: !pane.active } } + Component { id: gridView; FolderGrid { zoom: pane.zoom; inactive: pane.keysElsewhere; onZoomRequested: (step) => pane.zoomBy(step) } } + Component { id: listView; FolderList { inactive: pane.keysElsewhere } } + Component { id: columnsView; FolderColumns { inactive: pane.keysElsewhere } } } // Drawn over the window's top bar. It reaches back over the sidebar for diff --git a/src/Main.qml b/src/Main.qml index 6c56a8b8..ffc812ad 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -66,6 +66,8 @@ ApplicationWindow { // Whether keys are going into a text field. The right pane can go before // the window does when it closes, leaving activePane null. readonly property bool typing: !!activePane && activePane.typing + // Whether Tab has put the keyboard in the sidebar or the details. + readonly property bool keysBesidePanes: sidebar.activeFocus || details.hasKeyboard // The rightmost pane's path bar reaches over the details column. readonly property int detailsWidth: details.visible ? details.width : 0 // Whether the window is wide enough for the details column at all. diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 669adee3..ee0c9989 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -5084,6 +5084,56 @@ private slots: QCOMPARE(window->property("answer").toString(), QString()); } + void selectionDropsTheAccentWhileTheKeysAreInTheSidebar() + { + // Shift+Tab puts the keyboard in the sidebar, and the files' view draws its selection without the accent until Tab brings it back. + QTemporaryDir home; + write(home.filePath("a.txt"), "a"); + // The window remembers its view in QSettings; here that file lands in the test's home, not the user's. + QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, home.path()); + Theme theme(home.filePath("theme")); + Translator translator(home.filePath("language"), sourceFolder() + "/i18n"); + Places places(home.path()); + Plugins plugins("en", {}, home.filePath("folio.conf")); + Backend backend(home.path()); + FileOps ops; + QQmlEngine engine; + qmlRegisterType("Folio", 1, 0, "FolderModel"); + engine.rootContext()->setContextProperty("translator", &translator); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("places", &places); + engine.rootContext()->setContextProperty("plugins", &plugins); + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("fileOps", &ops); + QQmlComponent component(&engine, QUrl::fromLocalFile(sourceFolder() + "/Main.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + backend.navigate(home.path()); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window)); + + // The files' view, whichever of the three it is. + std::function findView = [&](QQuickItem *item) -> QQuickItem * { + if (QByteArray(item->metaObject()->className()).startsWith("Folder") && item->property("inactive").isValid()) + return item; + for (QQuickItem *child : item->childItems()) + if (QQuickItem *found = findView(child)) + return found; + return nullptr; + }; + QQuickItem *view = nullptr; + QTRY_VERIFY((view = findView(window->contentItem()))); + QTRY_VERIFY(!view->property("inactive").toBool()); + + // The window takes no keys until it has settled. + QTRY_VERIFY(window->property("settled").toBool()); + QTest::keyClick(window, Qt::Key_Backtab, Qt::ShiftModifier); + QTRY_VERIFY(view->property("inactive").toBool()); + QTest::keyClick(window, Qt::Key_Tab); + QTRY_VERIFY(!view->property("inactive").toBool()); + } + void formattingOffersWhatThisMachineCanMake() { // exFAT first, and only the filesystems whose tools are installed.