From edd94f9109ff02682afea6b7394909e26ba44e8b Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:35:52 -0400 Subject: [PATCH 01/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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. From f71fd55807906502df1cd36194c89c7a004a24ae Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 09:56:12 -0400 Subject: [PATCH 21/61] Fold whole crumbs into a leading ellipsis A path wider than its bar was cut through the middle of a crumb. Crumbs that do not fit now fold whole into a leading ellipsis that goes to the deepest folded folder and names the folded path in a tooltip; only a lone current folder elides. Mirrored, the ellipsis leads from the right. --- src/PathBar.qml | 79 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/src/PathBar.qml b/src/PathBar.qml index 47c25d28..965bb1ec 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Controls import QtQuick.Layouts // The breadcrumb path from the left edge; view buttons on the right. Back and @@ -128,29 +129,73 @@ Rectangle { Keys.onEscapePressed: bar.endSearch() } - Flickable { + Item { + id: trail visible: !bar.editing && !bar.searching Layout.fillWidth: true Layout.fillHeight: true - contentWidth: Math.max(width, crumbs.implicitWidth) clip: true - boundsBehavior: Flickable.StopAtBounds - // Long paths keep their end in view; that's where you are. - // Mirrored, the end is on the left. - function revealEnd() { contentX = LayoutMirroring.enabled ? 0 : Math.max(0, contentWidth - width) } - onContentWidthChanged: revealEnd() - onWidthChanged: revealEnd() - LayoutMirroring.onEnabledChanged: revealEnd() + // The end stays in view; crumbs that don't fit whole fold into "…" at the reading start (the right, when mirrored). + property int first: 0 + property real offset: 0 + // Measured from each crumb's full width, so the current folder's own elide never changes what folds. + function fit() { + let rest = 0 + for (let k = 0; k < crumbRepeater.count; ++k) + rest += crumbRepeater.itemAt(k).fullWidth + (k > 0 ? crumbs.spacing : 0) + let i = 0 + if (rest > width) + while (i < crumbRepeater.count - 1 && rest > width - more.implicitWidth - crumbs.spacing) + rest -= crumbRepeater.itemAt(i++).fullWidth + crumbs.spacing + first = i + // How far the first shown crumb is from the row's start, which a mirrored Row puts at its right. + const shown = crumbRepeater.itemAt(i) + const lead = LayoutMirroring.enabled ? crumbs.width - shown.x - shown.width : shown.x + offset = i > 0 ? lead - more.implicitWidth - crumbs.spacing : 0 + } + onWidthChanged: fit() + LayoutMirroring.onEnabledChanged: fit() + + Text { + id: more + // Above the folded crumbs, which still lie under it and would take its hover. + z: 1 + visible: trail.first > 0 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "…" + color: moreMouse.containsMouse ? theme.foreground : theme.secondaryForeground + font.pixelSize: 14 + // A crumb's hit margin, except toward the bar's edge, which would fall outside the clip. + MouseArea { + id: moreMouse + anchors.fill: parent + anchors.margins: -4 + anchors.leftMargin: 0 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: backend.navigate(backend.crumbs[trail.first - 1].path) + // The folded path as the crumbs write it: "/" root, "~" home. + ToolTip { + visible: moreMouse.containsMouse + delay: 600 + text: crumbRepeater.model.slice(0, trail.first).map(c => c.name).join("/").replace("//", "/") + enter: null + exit: null + } + } + } Row { id: crumbs - // A Row isn't placed by its Flickable, so it's moved to the - // right edge by hand when mirrored. - x: LayoutMirroring.enabled ? parent.width - width : 0 + // A Row isn't placed by its parent, so it's moved to the right edge by hand when mirrored. + x: LayoutMirroring.enabled ? trail.width - width + trail.offset : -trail.offset anchors.verticalCenter: parent.verticalCenter spacing: 10 + onPositioningComplete: trail.fit() Repeater { + id: crumbRepeater // Recents, Trash and plugins' views are named in C++, which // retranslating QML doesn't redo. model: { translator.language; return backend.crumbs } @@ -159,8 +204,13 @@ Rectangle { required property var modelData required property int index readonly property bool last: index === backend.crumbs.length - 1 + readonly property real fullWidth: folderName.implicitWidth + (separator.visible ? separator.implicitWidth + spacing : 0) + // Folded crumbs keep their place so the others don't move. + opacity: index < trail.first ? 0 : 1 + enabled: index >= trail.first spacing: 10 Text { + id: separator // No separator after / itself: "/ run", not "/ / run". visible: crumb.index > 0 && !(crumb.index === 1 && backend.crumbs[0].name === "/") text: "/" @@ -168,6 +218,11 @@ Rectangle { font.pixelSize: 14 } Text { + id: folderName + // The current folder always shows; alone too wide, it elides. + width: crumb.last ? Math.max(0, Math.min(implicitWidth, trail.width - (separator.visible ? separator.implicitWidth + crumb.spacing : 0) + - (trail.first > 0 ? more.implicitWidth + crumbs.spacing : 0))) : implicitWidth + elide: Text.ElideRight text: crumb.modelData.name color: crumb.last ? theme.brightForeground : crumbMouse.containsMouse ? theme.foreground : theme.secondaryForeground font.pixelSize: 14 From 841100dc4686221e58ec52db3230ed69ac46c3c0 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:39:25 -0400 Subject: [PATCH 22/61] Make Home a row of its own at the top of the sidebar The HOME heading was the only mouse route home and looked like the inert labels. Home is now the first row of the first section, with its glyph, taking drops like any folder row; Recents and Trash end that section in place of a Views heading. --- plans/folio.md | 2 +- src/Glyph.qml | 1 + src/Sidebar.qml | 66 +++++++++++++++++--------------- src/i18n/en.json | 1 - tests/folio_tests.cpp | 88 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 125 insertions(+), 33 deletions(-) diff --git a/plans/folio.md b/plans/folio.md index fade4dca..d1955fe6 100644 --- a/plans/folio.md +++ b/plans/folio.md @@ -34,7 +34,7 @@ Done, tested, and verified offscreen: - `/` searches this folder and every folder below it by name as you type: a live breadth-first walk, nearest first, that stays on the folder's drive and doesn't follow symlinked folders. - Three views, remembered between launches: grid (Ctrl+1), list with name, size and date (Ctrl+2; no headings; the date then the size step aside when narrow), and Finder-style columns (Ctrl+3). - Preview pane: large preview, the system's type name, size, modified, dimensions, opening lines of text files, Open. -- Sidebar: a Home section (its label goes home) listing every folder in home (XDG folders and Dropbox with their own glyphs, `omarchy` shown as Omarchy); Pinned folders (Ctrl+D or drag onto the sidebar) stored as GTK bookmarks; Dropbox (with `dropbox-cli` sync status), Trash, physical drives (removable and extra internal disks, not the system's own), online tailnet machines from `tailscale status --json`. +- Sidebar: a Home section holding Home, every folder in home (XDG folders and Dropbox with their own glyphs, `omarchy` shown as Omarchy), Recents and Trash; Pinned folders (Ctrl+D or drag onto the sidebar) stored as GTK bookmarks; Dropbox (with `dropbox-cli` sync status), physical drives (removable and extra internal disks, not the system's own), online tailnet machines from `tailscale status --json`. - Tailnet machines open over SFTP through GIO, landing in the matching home folder; their host keys are accepted without asking (Tailscale already checked the peer), and a password, when keys aren't set up, is asked in the window. - Mouse back/forward buttons move through history (in the preview: previous/next file). - Space opens a large preview in its own process (`folio preview`), like Sushi: pictures at full size with zoom, animated GIF/WebP, video and audio playing (Qt Multimedia's FFmpeg backend), PDFs scrolling (Qt PDF), text and rendered Markdown, folders with their pictures, and the largest cached thumbnail for anything else. Arrows step through the folder and the grid follows; a crash in the preview is reported in the Folio window. Ctrl+I toggles the details pane. diff --git a/src/Glyph.qml b/src/Glyph.qml index dc33af44..dbced627 100644 --- a/src/Glyph.qml +++ b/src/Glyph.qml @@ -85,6 +85,7 @@ Item { case "audio": return "M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2" case "check": return "M5 12l5 5 9-10" case "close": return "M6 6l12 12M18 6L6 18" + case "home": return "M4 10l8-6 8 6v10H4zM10 20v-6h4v6" default: return page() } } diff --git a/src/Sidebar.qml b/src/Sidebar.qml index 732b9ee6..8e79d739 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -32,11 +32,12 @@ Rectangle { // Each plugin section's rows, then the row for its view. function sectionRows(section) { return section.view ? section.rows.concat([section.view]) : section.rows } readonly property var pluginRows: plugins.sections.reduce((all, section) => all.concat(sectionRows(section)), []) - readonly property var rows: [].concat(places.places, places.pinned, places.views, places.devices, pluginRows) - readonly property int pinnedAt: places.places.length - readonly property int viewsAt: pinnedAt + places.pinned.length - readonly property int devicesAt: viewsAt + places.views.length + readonly property var rows: [].concat([homeRow], places.places, places.views, places.pinned, places.devices, pluginRows) + readonly property int pinnedAt: viewsAt + places.views.length + readonly property int viewsAt: 1 + places.places.length + readonly property int devicesAt: pinnedAt + places.pinned.length readonly property int pluginsAt: devicesAt + places.devices.length + readonly property var homeRow: ({ path: places.home }) // Where a plugin section's rows start in `rows`. function sectionAt(index) { let at = pluginsAt @@ -89,7 +90,7 @@ Rectangle { // A pinned folder is unpinned, a plugin's item taken off the sidebar. Keys.onDeletePressed: { const row = rows[cursor] - if (cursor >= pinnedAt && cursor < viewsAt) + if (cursor >= pinnedAt && cursor < devicesAt) places.unpin(row.path) else if (row && row.forgettable) plugins.forget(row.plugin, row.id) @@ -125,7 +126,7 @@ Rectangle { readonly property string activePath: { let best = "" const here = sidebar.target.path - for (const list of [places.places, places.pinned, places.views, places.devices, + for (const list of [[homeRow], places.places, places.pinned, places.views, places.devices, plugins.sections.filter(section => section.view).map(section => section.view)]) { for (const p of list) { if (p.path !== "/" && (here === p.path || here.startsWith(p.path + "/")) && p.path.length > best.length) @@ -275,15 +276,19 @@ Rectangle { width: sidebar.width - 24 spacing: 1 - // The section's label is home itself: click it to go there. SectionLabel { Layout.topMargin: 0 text: qsTr("Home") - color: sidebar.target.path === places.home ? theme.accent - : homeHover.hovered ? theme.foreground : theme.secondaryForeground - Accessible.role: Accessible.Button - HoverHandler { id: homeHover; cursorShape: Qt.PointingHandCursor } - TapHandler { onTapped: sidebar.target.navigate(places.home) } + } + PlaceRow { + dropPath: places.home + onDropHoverChanged: sidebar.dropHovers += dropHover ? 1 : -1 + keyed: sidebar.activeFocus && sidebar.cursor === 0 + onKeyedChanged: if (keyed) sidebar.reveal(this) + text: qsTr("Home") + glyph: "home" + active: sidebar.activePath === places.home + onClicked: sidebar.target.navigate(places.home) } Repeater { model: places.places @@ -292,7 +297,7 @@ Rectangle { required property int index dropPath: modelData.path onDropHoverChanged: sidebar.dropHovers += dropHover ? 1 : -1 - keyed: sidebar.activeFocus && sidebar.cursor === 0 + index + keyed: sidebar.activeFocus && sidebar.cursor === 1 + index onKeyedChanged: if (keyed) sidebar.reveal(this) text: modelData.name glyph: modelData.icon @@ -301,6 +306,23 @@ Rectangle { } } + // Views: lists that aren't a folder of their own. + Repeater { + model: places.views + PlaceRow { + required property var modelData + required property int index + dropTrashes: modelData.icon === "trash" + onDropHoverChanged: sidebar.dropHovers += dropHover ? 1 : -1 + text: modelData.icon === "trash" ? qsTr("Trash") : qsTr("Recents") + glyph: modelData.icon + active: sidebar.activePath === modelData.path + onClicked: sidebar.target.navigate(modelData.path) + keyed: sidebar.activeFocus && sidebar.cursor === sidebar.viewsAt + index + onKeyedChanged: if (keyed) sidebar.reveal(this) + } + } + // Pinned folders from anywhere, kept in GTK's bookmarks. While a // folder is dragged over the sidebar, the section shows (even // empty) as the place to drop it. @@ -365,24 +387,6 @@ Rectangle { } } - // Views: lists that aren't a folder of their own. - SectionLabel { text: qsTr("Views") } - Repeater { - model: places.views - PlaceRow { - required property var modelData - required property int index - dropTrashes: modelData.icon === "trash" - onDropHoverChanged: sidebar.dropHovers += dropHover ? 1 : -1 - text: modelData.icon === "trash" ? qsTr("Trash") : qsTr("Recents") - glyph: modelData.icon - active: sidebar.activePath === modelData.path - onClicked: sidebar.target.navigate(modelData.path) - keyed: sidebar.activeFocus && sidebar.cursor === sidebar.viewsAt + index - onKeyedChanged: if (keyed) sidebar.reveal(this) - } - } - // Each drive says how much is free under its name, where there's // room for it, and while it's hovered or has the keyboard its icon // becomes the button that ejects it. Right-clicked, it has a menu diff --git a/src/i18n/en.json b/src/i18n/en.json index 550544ce..e5b3c23c 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -448,7 +448,6 @@ "Untitled Folder": "Untitled Folder", "Untitled Folder %1": "Untitled Folder %1", "User": "User", - "Views": "Views", "Works almost anywhere, old cameras and car stereos too, but no file can be over 4 GB.": "Works almost anywhere, old cameras and car stereos too, but no file can be over 4 GB.", "Works with Mac, Windows and Linux, and holds big files.": "Works with Mac, Windows and Linux, and holds big files.", "You don't have permission to open this folder.": "You don't have permission to open this folder.", diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index ee0c9989..ef37f167 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -5134,6 +5134,94 @@ private slots: QTRY_VERIFY(!view->property("inactive").toBool()); } + void sidebarDeleteUnpinsOnlyPinnedRows() + { + // Delete unpins the keyed pinned row wherever that section sits, and leaves Recents and Trash alone. + QTemporaryDir home, files; + for (const char *dir : {"Work/folio", "Work/hype"}) + QDir(home.path()).mkpath(dir); + QDir(files.path()).mkpath("plugins"); + const auto dataHome = dataHomeAt(files.filePath("data")); + // The Trash view's own folder, pinned too, so a Delete that reached the views would unpin it. + const QString trashFolder = QDir::cleanPath(FileOps::homeTrash() + "/files"); + QVERIFY(QDir().mkpath(trashFolder)); + Places places(home.path()); + QCOMPARE(places.pin(home.filePath("Work/folio")), QString()); + QCOMPARE(places.pin(home.filePath("Work/hype")), QString()); + QCOMPARE(places.pin(trashFolder), QString()); + Plugins plugins("en", {files.filePath("plugins")}, files.filePath("folio.conf")); + Backend backend(home.path()); + backend.navigate(home.path()); + Theme theme(files.filePath("theme")); + FileOps fileOps; + QQmlEngine engine; + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("places", &places); + engine.rootContext()->setContextProperty("plugins", &plugins); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("fileOps", &fileOps); + engine.rootContext()->setContextProperty("translator", QVariantMap{{"language", "en"}}); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window {\n" + " width: 300; height: 900; visible: true\n" + " Sidebar { id: sidebar; anchors.fill: parent }\n" + " property alias sidebar: sidebar\n" + " function keyed() { return sidebar.rows[sidebar.cursor].path }\n" + "}\n", + QUrl::fromLocalFile(sourceFolder() + "/SidebarDeleteTest.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window)); + auto *sidebar = window->property("sidebar").value(); + const auto keyed = [&] { + QVariant path; + QMetaObject::invokeMethod(window, "keyed", Q_RETURN_ARG(QVariant, path)); + return path.toString(); + }; + const auto rows = [&] { return sidebar->property("rows").toList().size(); }; + const auto at = [&](const char *name) { return sidebar->property(name).toInt(); }; + const auto pinnedPath = [&](int i) { return places.pinned().at(i).toMap().value("path").toString(); }; + // At most one key per row, so a row the keys cannot reach fails instead of hanging. + const auto moveTo = [&](int row) { + for (int step = 0; step < rows() && at("cursor") != row; ++step) + QTest::keyClick(window, at("cursor") < row ? Qt::Key_Down : Qt::Key_Up); + return at("cursor") == row; + }; + + QVERIFY(QMetaObject::invokeMethod(sidebar, "focusSidebar")); + QVERIFY(sidebar->property("activeFocus").toBool()); + QCOMPARE(at("cursor"), 0); + QCOMPARE(places.views().size(), 2); + QCOMPARE(places.views().at(1).toMap().value("path").toString(), trashFolder); + QCOMPARE(places.pinned().size(), 3); + + // Recents and Trash are views, not pins. + const int before = rows(); + for (int view = 0; view < 2; ++view) { + QVERIFY(moveTo(at("viewsAt") + view)); + QCOMPARE(keyed(), places.views().at(view).toMap().value("path").toString()); + QTest::keyClick(window, Qt::Key_Delete); + QCOMPARE(places.pinned().size(), 3); + QVERIFY(places.isPinned(trashFolder)); + QCOMPARE(rows(), before); + } + + // The last pinned row, then the first: both ends of the range. + const QString first = pinnedPath(0), middle = pinnedPath(1), last = pinnedPath(2); + QVERIFY(moveTo(at("pinnedAt") + 2)); + QCOMPARE(keyed(), last); + QTest::keyClick(window, Qt::Key_Delete); + QVERIFY(!places.isPinned(last)); + QVERIFY(moveTo(at("pinnedAt"))); + QCOMPARE(keyed(), first); + QTest::keyClick(window, Qt::Key_Delete); + QVERIFY(!places.isPinned(first)); + QVERIFY(places.isPinned(middle)); + } + void formattingOffersWhatThisMachineCanMake() { // exFAT first, and only the filesystems whose tools are installed. From 505b83753b2ccdfd8fc81dd2497ee14e3f3bdc05 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:39:31 -0400 Subject: [PATCH 23/61] Draw the open place grey while the files hold the keyboard The sidebar row for where you are stayed accent-lit while the files had the keyboard, so two accent things competed. It is grey until the sidebar takes the keyboard, then accent as before. --- src/Sidebar.qml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Sidebar.qml b/src/Sidebar.qml index 8e79d739..11b96f50 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -184,7 +184,9 @@ Rectangle { background: Rectangle { radius: Math.min(4, theme.radius) - color: row.active || row.dropHover ? Qt.alpha(theme.accent, 0.16) + // Accent only while the sidebar holds the keyboard; grey while the files do. + color: row.dropHover || row.active && sidebar.activeFocus ? Qt.alpha(theme.accent, 0.16) + : row.active ? Qt.alpha(theme.foreground, 0.10) : row.hovered && row.enabled ? Qt.alpha(theme.foreground, 0.06) : "transparent" border.width: row.dropHover ? 2 : row.keyed ? 1 : 0 border.color: theme.accent From 1c973ab4d1f2f40ba235cdcc451054e6aca727c3 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 00:50:27 -0400 Subject: [PATCH 24/61] Say a toolbar toggle is on by brightness, not a plate An on button sat on an accent plate, a second accent beside the folder's own. The details button now draws off in the secondary ink, and on in whichever of foreground and brightForeground stands out more; no plate. --- src/IconButton.qml | 9 ++++++++- src/PathBar.qml | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/IconButton.qml b/src/IconButton.qml index 3ca21db3..a3c5e6c6 100644 --- a/src/IconButton.qml +++ b/src/IconButton.qml @@ -8,6 +8,11 @@ AbstractButton { property string glyph property bool active: false + // An on/off switch: off draws in the secondary ink, so on and off differ by brightness alone. + property bool toggle: false + // Some themes set brightForeground dimmer than foreground; on takes whichever stands out more. + readonly property color onInk: (theme.brightForeground.hslLightness > theme.foreground.hslLightness) !== theme.light + ? theme.brightForeground : theme.foreground implicitWidth: 36 implicitHeight: 36 @@ -26,7 +31,8 @@ AbstractButton { background: Rectangle { radius: Math.min(4, theme.radius) - color: button.active ? Qt.alpha(theme.accent, 0.16) + // A toggle says on by its ink alone; any other active button keeps the plate. + color: button.active && !button.toggle ? Qt.alpha(theme.accent, 0.16) : button.hovered && button.enabled ? Qt.alpha(theme.foreground, 0.08) : "transparent" } @@ -36,6 +42,7 @@ AbstractButton { anchors.centerIn: parent name: button.glyph color: !button.enabled ? Qt.alpha(theme.foreground, 0.35) + : button.toggle ? (button.active ? button.onInk : theme.secondaryForeground) : button.active ? theme.brightForeground : theme.foreground } } diff --git a/src/PathBar.qml b/src/PathBar.qml index 965bb1ec..f5b3328e 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -264,6 +264,7 @@ Rectangle { SortMenu { id: sortMenu } IconButton { glyph: "preview" + toggle: true // Only where the window has room to show them. visible: bar.previewFits text: qsTr("Details") From d54a00844fd63277c42e0a7bee1db1f8c820fbc2 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 08:30:50 -0400 Subject: [PATCH 25/61] Edit the path with a click on the empty part of the bar Only Ctrl+L and a click on the current folder made the path editable. A click beside the crumbs now opens the field too, and a double click there keeps the whole path selected; the other crumbs and the ellipsis keep their clicks. --- src/PathBar.qml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/PathBar.qml b/src/PathBar.qml index f5b3328e..33ddf33a 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -86,6 +86,12 @@ Rectangle { cursorPosition = text.length } onActiveFocusChanged: if (!activeFocus && bar.editing) bar.stopEditing() + // A double click on the empty bar lands its second click here: keep the whole path selected, not a word. + MouseArea { + anchors.fill: parent + onPressed: mouse => mouse.accepted = (mouse.flags & Qt.MouseEventCreatedDoubleClick) !== 0 && pathField.selectedText === pathField.text + onDoubleClicked: mouse => mouse.accepted = pressed + } } PlainField { @@ -156,6 +162,12 @@ Rectangle { onWidthChanged: fit() LayoutMirroring.onEnabledChanged: fit() + // A click on the empty part of the bar edits the path, as Ctrl+L does. + MouseArea { + anchors.fill: parent + onClicked: bar.editPath() + } + Text { id: more // Above the folded crumbs, which still lie under it and would take its hover. From 031fb043b5448c13ccc06cc675fb33a31e8558e4 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 07:29:15 -0400 Subject: [PATCH 26/61] Say how many a search found once it settles While a search showed its results, the field still read "Search X and below", so a short list could not be told from a cut one. Once the results for what is typed are in, it reads "N found in X and below"; a plugin's view keeps its plain "Search X". --- src/PathBar.qml | 8 +++++++- src/foldermodel.h | 6 +++++- src/i18n/en.json | 8 ++++++++ tests/folio_tests.cpp | 16 ++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/PathBar.qml b/src/PathBar.qml index 33ddf33a..bf2a4602 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -113,10 +113,16 @@ Rectangle { translator.language return backend.crumbs.length > 0 ? backend.crumbs[backend.crumbs.length - 1].name : "" } + // Once the results for what is typed are in, how many there are. + readonly property bool settled: backend.folder.query.length > 0 && backend.folder.query === searchField.text.trim() + && !backend.folder.loading && backend.folder.error.length === 0 anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter - text: backend.path.startsWith("plugin:") ? qsTr("Search %1").arg(here) : qsTr("Search %1 and below").arg(here) + text: backend.path.startsWith("plugin:") ? qsTr("Search %1").arg(here) + : !settled ? qsTr("Search %1 and below").arg(here) + : backend.folder.count >= backend.folder.searchLimit ? qsTr("%Ln or more found in %1 and below", "", backend.folder.count).arg(here) + : qsTr("%Ln found in %1 and below", "", backend.folder.count).arg(here) color: theme.secondaryForeground font.pixelSize: 12 } diff --git a/src/foldermodel.h b/src/foldermodel.h index 386cab1b..c0175778 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -34,6 +34,8 @@ class FolderModel : public QAbstractListModel { 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) + // A search stops at this many matches, so a count this high may be cut. + Q_PROPERTY(int searchLimit READ searchLimit CONSTANT) public: enum Role { @@ -149,8 +151,10 @@ class FolderModel : public QAbstractListModel { // named by its path from `root`. Stops at `limit` matches, at other // filesystems and at symlinks, and as soon as `stop` returns true. static QVector search(const QString &root, const QString &query, bool showHidden, const QString &sort, - const std::function &stop = {}, int limit = 1000, + const std::function &stop = {}, int limit = maxSearchResults, const QLocale &locale = QLocale()); + static constexpr int maxSearchResults = 1000; + int searchLimit() const { return maxSearchResults; } signals: void pathChanged(); diff --git a/src/i18n/en.json b/src/i18n/en.json index e5b3c23c..2a1c23af 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -36,6 +36,10 @@ "one": "%Ln folder", "other": "%Ln folders" }, + "%Ln found in %1 and below": { + "one": "%Ln found in %1 and below", + "other": "%Ln found in %1 and below" + }, "%Ln items": { "one": "%Ln item", "other": "%Ln items" @@ -48,6 +52,10 @@ "one": "%Ln item will be deleted permanently. This can't be undone.", "other": "%Ln items will be deleted permanently. This can't be undone." }, + "%Ln or more found in %1 and below": { + "one": "%Ln or more found in %1 and below", + "other": "%Ln or more found in %1 and below" + }, "A folder, or a server's address like sftp://host/": "A folder, or a server's address like sftp://host/", "A name can't contain \"/\".": "A name can't contain \"/\".", "Add or remove one from the selection": "Add or remove one from the selection", diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index ef37f167..0f0dc54a 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6631,6 +6631,22 @@ private slots: QVERIFY(!QFile::exists(from.filePath("near.txt"))); } + void folderSearchLimitIsWhatTheSearchStopsAt() + { + // The search field reads a count at this limit as "or more", so it must be the one the search uses. + QTemporaryDir dir; + for (int i = 0; i < FolderModel::maxSearchResults + 1; ++i) + write(dir.filePath(QString("match-%1.txt").arg(i)), ""); + // Through the model's own query, the count stops at the limit the field reads. + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + model.setQuery("match"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), model.property("searchLimit").toInt()); + } + 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 2b7d22d2b7ec13b18d9e811a4bf1e5b950654308 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 03:12:00 -0400 Subject: [PATCH 27/61] Say how to leave a search that found nothing A search with no results said "Nothing matches" and stopped there, with no next step. A quieter line under it now says Escape ends the search, in the grid, the list and the columns. --- src/FolderColumns.qml | 9 +++++++++ src/FolderGrid.qml | 10 ++++++++++ src/FolderList.qml | 10 ++++++++++ src/i18n/en.json | 1 + 4 files changed, 30 insertions(+) diff --git a/src/FolderColumns.qml b/src/FolderColumns.qml index b4ec7667..4f98d277 100644 --- a/src/FolderColumns.qml +++ b/src/FolderColumns.qml @@ -187,6 +187,15 @@ Item { visible: here.count === 0 && !backend.folder.loading && backend.folder.error.length === 0 text: backend.folder.query ? qsTr("No matches") : qsTr("No items") color: theme.secondaryForeground + Text { + anchors.top: parent.bottom + anchors.topMargin: 6 + anchors.left: parent.left + visible: backend.folder.query.length > 0 + text: qsTr("Escape ends the search") + color: theme.secondaryForeground + font.pixelSize: 12 + } } } Separator { } diff --git a/src/FolderGrid.qml b/src/FolderGrid.qml index c68e502b..3e198993 100644 --- a/src/FolderGrid.qml +++ b/src/FolderGrid.qml @@ -109,5 +109,15 @@ GridView { text: backend.folder.query ? qsTr("Nothing matches “%1”").arg(backend.folder.query) : plugins.views[backend.path] ? plugins.views[backend.path].empty : qsTr("This folder is empty") color: theme.secondaryForeground + // The way out of a search that found nothing. + Text { + anchors.top: parent.bottom + anchors.topMargin: 6 + anchors.horizontalCenter: parent.horizontalCenter + visible: backend.folder.query.length > 0 + text: qsTr("Escape ends the search") + color: theme.secondaryForeground + font.pixelSize: 12 + } } } diff --git a/src/FolderList.qml b/src/FolderList.qml index c6dfaf7c..e15bb08d 100644 --- a/src/FolderList.qml +++ b/src/FolderList.qml @@ -71,5 +71,15 @@ ListView { text: backend.folder.query ? qsTr("Nothing matches “%1”").arg(backend.folder.query) : plugins.views[backend.path] ? plugins.views[backend.path].empty : qsTr("This folder is empty") color: theme.secondaryForeground + // The way out of a search that found nothing. + Text { + anchors.top: parent.bottom + anchors.topMargin: 6 + anchors.horizontalCenter: parent.horizontalCenter + visible: backend.folder.query.length > 0 + text: qsTr("Escape ends the search") + color: theme.secondaryForeground + font.pixelSize: 12 + } } } diff --git a/src/i18n/en.json b/src/i18n/en.json index 2a1c23af..9ddf1c87 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -184,6 +184,7 @@ "other": "Emptying the trash: %L1 of %Ln items…" }, "Emptying the trash…": "Emptying the trash…", + "Escape ends the search": "Escape ends the search", "Everything else, for files and for drives in the sidebar": "Everything else, for files and for drives in the sidebar", "Everything on this drive will be erased. This can't be undone.": "Everything on this drive will be erased. This can't be undone.", "Extract": "Extract", From 697c4446857d043e3af27160931309047c0af936 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 03:12:37 -0400 Subject: [PATCH 28/61] Keep the empty caption level while a band shows With the notice band or the Empty Trash band above the files, "This folder is empty" dropped by half the band's height below "Nothing selected". The caption now keeps the pane's middle line under any band. --- src/FolderGrid.qml | 3 +++ src/FolderList.qml | 3 +++ src/FolderPane.qml | 8 ++++++-- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/FolderGrid.qml b/src/FolderGrid.qml index 3e198993..f6df0f98 100644 --- a/src/FolderGrid.qml +++ b/src/FolderGrid.qml @@ -7,6 +7,8 @@ GridView { // In a pane the keys don't go to: the selection is drawn without the accent. property bool inactive: false + // Keeps the empty caption on the pane's middle line, level with the details pane's, while bands push the view down. + property real captionOffset: 0 function select(row, keep) { selecting.select(row, keep) } // A click on empty space lets go of the cursor; the pane then keeps it @@ -105,6 +107,7 @@ GridView { Text { anchors.centerIn: parent + anchors.verticalCenterOffset: grid.captionOffset visible: grid.count === 0 && !backend.folder.loading && backend.folder.error.length === 0 text: backend.folder.query ? qsTr("Nothing matches “%1”").arg(backend.folder.query) : plugins.views[backend.path] ? plugins.views[backend.path].empty : qsTr("This folder is empty") diff --git a/src/FolderList.qml b/src/FolderList.qml index e15bb08d..f275ac34 100644 --- a/src/FolderList.qml +++ b/src/FolderList.qml @@ -8,6 +8,8 @@ ListView { // In a pane the keys don't go to: the selection is drawn without the accent. property bool inactive: false + // Keeps the empty caption on the pane's middle line, level with the details pane's, while bands push the view down. + property real captionOffset: 0 function select(row, keep) { selecting.select(row, keep) } // A click on empty space lets go of the cursor; the pane then keeps it @@ -67,6 +69,7 @@ ListView { Text { anchors.centerIn: parent + anchors.verticalCenterOffset: list.captionOffset visible: list.count === 0 && !backend.folder.loading && backend.folder.error.length === 0 text: backend.folder.query ? qsTr("Nothing matches “%1”").arg(backend.folder.query) : plugins.views[backend.path] ? plugins.views[backend.path].empty : qsTr("This folder is empty") diff --git a/src/FolderPane.qml b/src/FolderPane.qml index 37f79a83..c607dc89 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -88,6 +88,8 @@ FocusScope { readonly property bool inView: backend.path.startsWith("plugin:") readonly property bool fileKeys: keys && !inView Binding { target: backend.folder; property: "items"; value: (plugins.views[backend.path] || {}).items || [] } + // The bands showing above the files, which push the view down. + readonly property int bandsHeight: (banner.visible ? banner.height : 0) + (trashBand.visible ? trashBand.height : 0) function deleteSelected() { const paths = pane.selectedPaths() if (pane.inTrash) @@ -346,6 +348,7 @@ FocusScope { } // Problems and slow work are said in the window, not swallowed. Rectangle { + id: banner Layout.fillWidth: true implicitHeight: 40 visible: bannerText.text.length > 0 @@ -382,6 +385,7 @@ FocusScope { // The trash offers one thing: emptying it. Rectangle { + id: trashBand Layout.fillWidth: true implicitHeight: 48 visible: pane.inTrash && backend.folder.count > 0 @@ -425,8 +429,8 @@ FocusScope { items: view.item } } - Component { id: gridView; FolderGrid { zoom: pane.zoom; inactive: pane.keysElsewhere; onZoomRequested: (step) => pane.zoomBy(step) } } - Component { id: listView; FolderList { inactive: pane.keysElsewhere } } + Component { id: gridView; FolderGrid { zoom: pane.zoom; inactive: pane.keysElsewhere; captionOffset: -pane.bandsHeight / 2; onZoomRequested: (step) => pane.zoomBy(step) } } + Component { id: listView; FolderList { inactive: pane.keysElsewhere; captionOffset: -pane.bandsHeight / 2 } } Component { id: columnsView; FolderColumns { inactive: pane.keysElsewhere } } } From 7c3f37db56c3b5e028bfcc9fc8f7df6db453a353 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 03:12:56 -0400 Subject: [PATCH 29/61] Name what goes in the permanent delete title The card asked "Delete for good?" while its body said "deleted permanently", two names for one act, and left "be undone." on a line of its own. The title now names the file, or the count, and says permanently; the body says only that it cannot be undone. --- src/FolderPane.qml | 5 ++--- src/i18n/en.json | 8 ++++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/FolderPane.qml b/src/FolderPane.qml index c607dc89..f380172e 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -101,9 +101,8 @@ FocusScope { if (paths.length === 0) return const name = paths[0].slice(paths[0].lastIndexOf("/") + 1) - confirm.ask(qsTr("Delete for good?"), - paths.length === 1 ? qsTr("%1 will be deleted permanently. This can't be undone.").arg(name) - : qsTr("%Ln items will be deleted permanently. This can't be undone.", "", paths.length), + confirm.ask(paths.length === 1 ? qsTr("Delete “%1” permanently?").arg(name) : qsTr("Delete %Ln items permanently?", "", paths.length), + qsTr("This can't be undone."), qsTr("Delete"), () => fileOps.deleteForever(paths)) } function emptyTrash() { diff --git a/src/i18n/en.json b/src/i18n/en.json index 9ddf1c87..6364d811 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -28,7 +28,6 @@ "%1 of %2": "%1 of %2", "%1 printed too much.": "%1 printed too much.", "%1 stopped with code %2.": "%1 stopped with code %2.", - "%1 will be deleted permanently. This can't be undone.": "%1 will be deleted permanently. This can't be undone.", "%1 · %2": "%1 · %2", "%1 × %2": "%1 × %2", "%1: %2": "%1: %2", @@ -144,9 +143,13 @@ "Couldn't unmount %1: %2": "Couldn't unmount %1: %2", "Cut": "Cut", "Delete": "Delete", - "Delete for good?": "Delete for good?", + "Delete %Ln items permanently?": { + "one": "Delete %Ln item permanently?", + "other": "Delete %Ln items permanently?" + }, "Delete in the sidebar": "Delete in the sidebar", "Delete permanently (asks first)": "Delete permanently (asks first)", + "Delete “%1” permanently?": "Delete “%1” permanently?", "Deleted": "Deleted", "Delete…": "Delete…", "Deleting %L1 of %Ln items…": { @@ -409,6 +412,7 @@ "There's nothing in %1 to extract.": "There's nothing in %1 to extract.", "There's nothing to redo.": "There's nothing to redo.", "There's nothing to undo.": "There's nothing to undo.", + "This can't be undone.": "This can't be undone.", "This computer can't make %1 drives.": "This computer can't make %1 drives.", "This folder is empty": "This folder is empty", "This folder no longer exists.": "This folder no longer exists.", From 08644c7f39044030806f10329256ebfcf061a56e Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 03:13:01 -0400 Subject: [PATCH 30/61] List dragging files on the shortcut sheet The sheet named every key but not dragging, so nothing said that Ctrl turns a drop's move into a copy. It now has a row for both after the clipboard keys. --- src/HelpOverlay.qml | 1 + src/i18n/en.json | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/HelpOverlay.qml b/src/HelpOverlay.qml index 7bb64eb7..cf25a091 100644 --- a/src/HelpOverlay.qml +++ b/src/HelpOverlay.qml @@ -52,6 +52,7 @@ Rectangle { ["Ctrl+I", qsTr("Show or hide the details pane")], ["Ctrl+P", qsTr("Pin or unpin the folder (or drag it to the sidebar)")], ["Ctrl+C / X / V", qsTr("Copy, cut, paste files")], + [qsTr("Drag / Ctrl+drag"), qsTr("Move files to a folder / copy them")], ["Ctrl+Shift+C", qsTr("Copy the path as text")], ["F2 / Ctrl+Shift+D", qsTr("Rename / duplicate")], ["Ctrl+N", qsTr("New window")], diff --git a/src/i18n/en.json b/src/i18n/en.json index 6364d811..8ed3bd14 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -168,6 +168,7 @@ "one": "Do this for the other %Ln clash", "other": "Do this for the other %Ln clashes" }, + "Drag / Ctrl+drag": "Drag / Ctrl+drag", "Drag on empty space": "Drag on empty space", "Drives can't be changed without udisks2.": "Drives can't be changed without udisks2.", "Drop to pin": "Drop to pin", @@ -241,6 +242,7 @@ "Modified": "Modified", "Mount": "Mount", "Move between the sidebar, the panes and the details": "Move between the sidebar, the panes and the details", + "Move files to a folder / copy them": "Move files to a folder / copy them", "Move here": "Move here", "Move the selection": "Move the selection", "Move to the trash": "Move to the trash", From 0995642f0f26a7cf2745aaca6b0607719fe99aed Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 03:16:00 -0400 Subject: [PATCH 31/61] Draw the other pane's selection ring so it reads The grey ring of a selection in the pane without the keys was 2.6:1 on osaka-jade, under the 3:1 a mark needs. It is drawn at 60% of the foreground instead of 40%, 3.9:1 or more there, still without the accent. --- src/FileRow.qml | 2 +- src/FileTile.qml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/FileRow.qml b/src/FileRow.qml index 13fe728d..a9a15360 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -68,7 +68,7 @@ Item { anchors.rightMargin: 4 radius: Math.min(4, theme.radius) border.width: folderDrop.containsDrag ? 2 : row.cursor ? 1 : 0 - border.color: row.inactive && !folderDrop.containsDrag ? Qt.alpha(theme.foreground, 0.4) : theme.accent + border.color: row.inactive && !folderDrop.containsDrag ? Qt.alpha(theme.foreground, 0.6) : 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) diff --git a/src/FileTile.qml b/src/FileTile.qml index 840e9b15..9c965dbc 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -190,7 +190,7 @@ Item { : hover.hovered ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.foreground, 0.06)) : theme.lighterBackground strokeColor: (tile.current || tile.cursor) && !tile.inactive || folderDrop.containsDrag ? theme.accent - : tile.current || tile.cursor ? Qt.alpha(theme.foreground, 0.4) : "transparent" + : tile.current || tile.cursor ? Qt.alpha(theme.foreground, 0.6) : "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) } @@ -276,7 +276,7 @@ Item { height: parent.shown.height + 6 color: "transparent" border.width: tile.current ? 2 : 1 - border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accent : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.4 : 0.2) + border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accent : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.6 : 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } @@ -300,7 +300,7 @@ Item { anchors.margins: -3 color: "transparent" border.width: tile.current ? 2 : 1 - border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accent : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.4 : 0.2) + border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accent : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.6 : 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } From 0572e0c747881923b5187979db865f46f404474d Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 11:22:20 -0400 Subject: [PATCH 32/61] Say move or copy next to the pointer during a drop "Move here" sat at the foot of the drop frame, hundreds of pixels from the pointer in a tall pane or column. Every drop frame now draws it under the pointer, just below the dragged picture, inside the frame. --- src/FolderDrop.qml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/FolderDrop.qml b/src/FolderDrop.qml index fcea8af5..493ddbd2 100644 --- a/src/FolderDrop.qml +++ b/src/FolderDrop.qml @@ -34,9 +34,11 @@ FileDrop { border.color: theme.accent radius: Math.min(6, theme.radius) Text { - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: parent.bottom - anchors.bottomMargin: 12 + // A dragged tile's picture hangs 52 px below the pointer, so the label sits 8 px under that. + readonly property int belowPointer: 60 + readonly property int edgeMargin: 12 + x: Math.max(edgeMargin, Math.min(parent.width - width - edgeMargin, area.drag.x - parent.x - width / 2)) + y: Math.max(edgeMargin, Math.min(parent.height - height - edgeMargin, area.drag.y - parent.y + belowPointer)) text: area.copies ? qsTr("Copy here") : qsTr("Move here") color: theme.secondaryForeground font.pixelSize: 12 From 50dc803f2f09f13dbc0ac637d0dc28816e353eab Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 03:15:22 -0400 Subject: [PATCH 33/61] Drop the heading over the sidebar's Home row The first section's heading read "HOME" straight above the Home row, so the same word showed twice 30 px apart. The Home row now leads the section on its own; Pinned, Devices and Tailnet keep their headings. --- src/Sidebar.qml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Sidebar.qml b/src/Sidebar.qml index 11b96f50..8f87aaf1 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -278,10 +278,6 @@ Rectangle { width: sidebar.width - 24 spacing: 1 - SectionLabel { - Layout.topMargin: 0 - text: qsTr("Home") - } PlaceRow { dropPath: places.home onDropHoverChanged: sidebar.dropHovers += dropHover ? 1 : -1 From cef85445bd0269cb068ed401ca30dced8a8ef8da Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 03:15:41 -0400 Subject: [PATCH 34/61] Draw a disabled toolbar glyph clearly dimmer than off The disabled ink was a translucent foreground the stroke blended twice, so it drew at 4.0:1 beside an off toggle's 5.1:1 and the two looked alike. It is now an opaque half mix, 3.4:1 on osaka-jade and 1.5:1 from off. --- src/IconButton.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/IconButton.qml b/src/IconButton.qml index a3c5e6c6..889521de 100644 --- a/src/IconButton.qml +++ b/src/IconButton.qml @@ -41,7 +41,8 @@ AbstractButton { Glyph { anchors.centerIn: parent name: button.glyph - color: !button.enabled ? Qt.alpha(theme.foreground, 0.35) + // Opaque on the bar: a translucent stroke blends twice, so 0.35 drew at 4.0:1, next to off's 5.1:1. + color: !button.enabled ? Qt.tint(theme.background, Qt.alpha(theme.foreground, 0.5)) : button.toggle ? (button.active ? button.onInk : theme.secondaryForeground) : button.active ? theme.brightForeground : theme.foreground } From c83899408ddf2a9f37920fe3f67227ceb04397a5 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 07:30:06 -0400 Subject: [PATCH 35/61] Add a top bar button that splits the window Splitting was only Ctrl+Alt+N, with nothing on screen to find it by. The first pane's bar now ends with a split toggle beside details. Off, it opens the second pane; on, it closes it as Ctrl+W does, lit by brightness alone. Its tooltip names it without the key, as the bar's other buttons do. --- src/FolderPane.qml | 2 ++ src/Glyph.qml | 1 + src/PathBar.qml | 11 +++++++++++ src/i18n/en.json | 2 ++ 4 files changed, 16 insertions(+) diff --git a/src/FolderPane.qml b/src/FolderPane.qml index f380172e..0d73f6ca 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -449,6 +449,8 @@ FocusScope { onTogglePreview: if (pane.host) pane.host.previewVisible = !pane.host.previewVisible onCycleView: pane.cycleView() onCloseRequested: pane.closeRequested() + split: !!pane.host && pane.host.split + onToggleSplit: if (pane.host) pane.host.split ? pane.host.closeSplit() : pane.host.openSplit() } // With two panes, a line along the top marks the one keys go to. diff --git a/src/Glyph.qml b/src/Glyph.qml index dbced627..4dac2df7 100644 --- a/src/Glyph.qml +++ b/src/Glyph.qml @@ -71,6 +71,7 @@ Item { case "list": return "M4 6h16M4 12h16M4 18h16" case "columns": return box(3, 4, 18, 16, r) + "M9 4v16M15 4v16" case "preview": return box(3, 5, 18, 14, r) + "M14 5v14" + case "split": return box(3, 5, 8, 14, r / 2) + box(13, 5, 8, 14, r / 2) // One per sort order: a slim arrow on the left (down for the usual // direction, up for oldest first), and what it sorts by on the right, // kept apart so both read at toolbar size. diff --git a/src/PathBar.qml b/src/PathBar.qml index bf2a4602..ecdddb7b 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -15,7 +15,10 @@ Rectangle { property bool editing: false // The right pane of a split has a close button (Ctrl+W). property bool closable: false + // The window is split; the first pane's bar has the button that splits and joins it. + property bool split: false signal closeRequested() + signal toggleSplit() signal togglePreview() signal cycleView() signal editDone() @@ -289,6 +292,14 @@ Rectangle { active: bar.previewVisible onClicked: bar.togglePreview() } + IconButton { + visible: !bar.closable + glyph: "split" + toggle: true + text: bar.split ? qsTr("Close the split") : qsTr("Split") + active: bar.split + onClicked: bar.toggleSplit() + } IconButton { visible: bar.closable glyph: "close" diff --git a/src/i18n/en.json b/src/i18n/en.json index 8ed3bd14..a78bd069 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -80,6 +80,7 @@ "Check the server": "Check the server", "Choose": "Choose", "Close": "Close", + "Close the split": "Close the split", "Close the window": "Close the window", "Close this pane": "Close this pane", "Compress": "Compress", @@ -390,6 +391,7 @@ "Sort: name, kind, newest, oldest, size": "Sort: name, kind, newest, oldest, size", "Sorted by kind": "Sorted by kind", "Sorted by name": "Sorted by name", + "Split": "Split", "Split into two panes / close the right one": "Split into two panes / close the right one", "Stop the file work under way, or dismiss what went wrong": "Stop the file work under way, or dismiss what went wrong", "Stopped after %L1 of %Ln items.": { From 54d61ba399b272d05f3babd2bf09f46dd7e3795f Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 03:26:59 -0400 Subject: [PATCH 36/61] Draw drive, list and archive glyphs as what they are A drive was a flat box, the list view three bars like a menu, and an archive took the plain page every unknown file gets. A drive now has a sloped top, the list view bullets, and an archive row a lidded box. --- src/FileRow.qml | 1 + src/Glyph.qml | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/FileRow.qml b/src/FileRow.qml index a9a15360..e0e98f9b 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -50,6 +50,7 @@ Item { : row.kind === "video" ? "videos" : row.kind === "audio" ? "music" : row.kind === "pdf" || row.kind === "text" ? "documents" + : row.kind === "archive" ? "archive" : "file" // A folder row takes files dropped on it, lighting up while they hover. diff --git a/src/Glyph.qml b/src/Glyph.qml index 4dac2df7..b16c3cfb 100644 --- a/src/Glyph.qml +++ b/src/Glyph.qml @@ -59,7 +59,8 @@ Item { case "videos": return box(3, 5, 18, 14, r) + "M10 9v6l5-3z" case "recent": return circle(12, 12, 9) + "M12 7v5l3 2" case "trash": return "M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" - case "drive": return box(3, 6, 18, 12, r) + "M7 14h2" + // A drive seen from the front: a sloped top over a box with a light. + case "drive": return "M3 12l4-7h10l4 7" + box(3, 12, 18, 7, r) + "M7 15.5h2" case "eject": return "M12 5l7 8H5zM5 18h14" case "peer": return box(4, 4, 16, 11, r) + "M9 20h6M12 15v5" // Machines joined to one another: a network of them. @@ -68,7 +69,8 @@ Item { case "back": return "M15 6l-6 6 6 6" case "forward": return "M9 6l6 6-6 6" case "grid": return box(4, 4, 6, 6, r / 2) + box(14, 4, 6, 6, r / 2) + box(4, 14, 6, 6, r / 2) + box(14, 14, 6, 6, r / 2) - case "list": return "M4 6h16M4 12h16M4 18h16" + // Bulleted, so it reads as a list of files rather than a menu. + case "list": return "M9 6h11M9 12h11M9 18h11M4 6h1M4 12h1M4 18h1" case "columns": return box(3, 4, 18, 16, r) + "M9 4v16M15 4v16" case "preview": return box(3, 5, 18, 14, r) + "M14 5v14" case "split": return box(3, 5, 8, 14, r / 2) + box(13, 5, 8, 14, r / 2) @@ -83,6 +85,8 @@ Item { case "play": return "M8 5v14l11-7z" case "pause": return "M8 5v14M16 5v14" case "open": return "M14 4h6v6M20 4l-9 9M18 14v6H4V6h6" + // A storage box: a lid over an open body with a handle. + case "archive": return box(3, 4, 18, 4, r / 2) + (r > 0 ? "M5 8V17Q5 19 7 19H17Q19 19 19 17V8" : "M5 8v11h14V8") + "M10 12h4" case "audio": return "M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2" case "check": return "M5 12l5 5 9-10" case "close": return "M6 6l12 12M18 6L6 18" From 78d7d51b6ac8b5af0ee237beb8cda25ffc3e2e72 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:10:02 -0400 Subject: [PATCH 37/61] Keep secondary text at 4.5:1 on every ground The secondary ink was a fixed mix of foreground into background, which drew under 4.5:1 on Rose Pine and Catppuccin Latte and on Gruvbox's tiles. It is now raised toward black or white just enough to reach 4.5:1 on the background, tiles and panels, and a theme that already reads keeps its mix. --- src/theme.cpp | 56 +++++++++++++++++++++++++++++++++++++++---- tests/folio_tests.cpp | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/theme.cpp b/src/theme.cpp index c3f44015..5a139e7b 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include namespace { @@ -43,6 +44,49 @@ QColor mix(const QColor &a, const QColor &b, double t) { a.blueF() * (1 - t) + b.blueF() * t); } +double ratio(const QColor &a, const QColor &b) { + const double x = luminance(a), y = luminance(b); + return (std::max(x, y) + .05) / (std::min(x, y) + .05); +} + +// WCAG's minimum contrast for text. +constexpr double textFloor = 4.5; +// The ground luminance where black and white read equally. +constexpr double midLuminance = .179; +// Bisection steps in ensureRatio, finer than one eight-bit level. +constexpr int bisectionSteps = 18; +// One eight-bit channel's range, so the rounding repair can never step further. +constexpr int channelRange = 255; + +// The ink when it already reads at need, else the least mix toward black or white that still does once rounded. +QColor ensureRatio(const QColor &ink, const QColor &ground, double need) { + if (ratio(ink, ground) >= need) + return ink; + const QColor toward = luminance(ground) > midLuminance ? QColor(Qt::black) : QColor(Qt::white); + double low = 0, high = 1; + QColor best = toward; + for (int i = 0; i < bisectionSteps; ++i) { + const double middle = (low + high) / 2; + const QColor candidate = mix(ink, toward, middle); + if (ratio(candidate, ground) >= need) { + best = candidate; + high = middle; + } else { + low = middle; + } + } + best = QColor(best.red(), best.green(), best.blue()); + const int step = toward == QColor(Qt::white) ? 1 : -1; + for (int i = 0; i < channelRange && ratio(best, ground) < need; ++i) { + const QColor next(qBound(0, best.red() + step, channelRange), qBound(0, best.green() + step, channelRange), + qBound(0, best.blue() + step, channelRange)); + if (next == best) + break; + best = next; + } + return best; +} + struct Palette { QHash colors; bool light = false; @@ -157,11 +201,14 @@ QColor Theme::accentForeground() const { return luminance(accent()) > .179 ? QColor("black") : QColor("white"); } -// Themes' own dim foregrounds are often too faint for small text, so derive a -// secondary color by mixing the foreground toward the background, keeping -// roughly a 4.5:1 contrast for metadata and labels. +// Metadata and labels: the foreground mixed toward the background, raised to textFloor on each ground they're on. QColor Theme::secondaryForeground() const { - return mix(background(), foreground(), 0.68); + // The foreground's share of the secondary ink before the floor. + constexpr double share = 0.68; + QColor ink = mix(background(), foreground(), share); + for (const QColor &ground : {background(), lighterBackground(), darkBackground()}) + ink = ensureRatio(ink, ground, textFloor); + return ink; } void Theme::reload() { @@ -190,3 +237,4 @@ void Theme::reload() { emit changed(); } } + diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 0f0dc54a..f36e51aa 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6647,6 +6647,24 @@ private slots: QCOMPARE(model.count(), model.property("searchLimit").toInt()); } + void themeFloorsTheSecondaryInk() + { + // Rose Pine Dawn's mix draws at 3.2:1 and under, Gruvbox's at 3.6:1 on a tile; Catppuccin Mocha's already reads. + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + for (const char *palette : {roseDawn, "background = \"#282828\"\ndark_background = \"#1e1e1e\"\n" + "lighter_background = \"#3c3836\"\nforeground = \"#d4be98\"\n"}) { + write(dir.filePath("theme/colors.toml"), palette); + Theme low(dir.path()); + for (const QColor &ground : {low.background(), low.lighterBackground(), low.darkBackground()}) + QVERIFY(contrast(low.secondaryForeground(), ground) >= 4.5); + QVERIFY(snug(low.secondaryForeground(), {low.background(), low.lighterBackground(), low.darkBackground()}, 4.5)); + } + write(dir.filePath("theme/colors.toml"), mocha); + Theme passing(dir.path()); + QCOMPARE(passing.secondaryForeground(), blend(QColor("#1e1e2e"), QColor("#cdd6f4"), 0.68)); + } + private: // Packs `items`, from `folder`, into `archive`, its kind from its name. static void pack(const QString &archive, const QString &folder, const QStringList &items) @@ -6674,6 +6692,38 @@ private slots: QVERIFY(file.open(QIODevice::WriteOnly)); file.write(contents); } + + // Stock Omarchy palettes: Rose Pine (Dawn, light) and Catppuccin (Mocha, dark). + static constexpr const char *roseDawn = "mode = \"light\"\naccent = \"#56949f\"\nbackground = \"#faf4ed\"\n" + "dark_background = \"#ede7e1\"\nlighter_background = \"#f2e9e1\"\nforeground = \"#575279\"\nred = \"#b4637a\"\n"; + static constexpr const char *mocha = "accent = \"#89b4fa\"\nbackground = \"#1e1e2e\"\ndark_background = \"#161622\"\n" + "lighter_background = \"#313244\"\nforeground = \"#cdd6f4\"\nred = \"#f38ba8\"\n"; + + static double contrast(const QColor &a, const QColor &b) + { + auto luminance = [](const QColor &c) { + auto linear = [](double v) { return v <= .04045 ? v / 12.92 : std::pow((v + .055) / 1.055, 2.4); }; + return .2126 * linear(c.redF()) + .7152 * linear(c.greenF()) + .0722 * linear(c.blueF()); + }; + const double x = luminance(a), y = luminance(b); + return (std::max(x, y) + .05) / (std::min(x, y) + .05); + } + + // A floored ink is the least that reads: it sits within 0.3 of the floor on at least one of its grounds. + static bool snug(const QColor &ink, std::initializer_list grounds, double need) + { + for (const QColor &ground : grounds) + if (contrast(ink, ground) < need + 0.3) + return true; + return false; + } + + // Theme's own mix, unrounded, so a passing theme is pinned to the exact color it had. + static QColor blend(const QColor &a, const QColor &b, double t) + { + return QColor::fromRgbF(a.redF() * (1 - t) + b.redF() * t, a.greenF() * (1 - t) + b.greenF() * t, + a.blueF() * (1 - t) + b.blueF() * t); + } }; QTEST_MAIN(FolioTests) From 4f5d6d8ebcd3867028dc3f2dc6b7bf285fd6a39a Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:10:02 -0400 Subject: [PATCH 38/61] Draw accent frames at 3:1 on every ground Focus, cursor and drop frames and the selection band used the raw accent, which drew under 3:1 on Rose Pine and on White's tiles. They now use accentMark, the accent raised just enough to reach 3:1, while fills and tints keep the accent itself. --- src/Band.qml | 2 +- src/ClashDialog.qml | 2 +- src/FileRow.qml | 2 +- src/FileTile.qml | 6 +++--- src/FolderDrop.qml | 2 +- src/FolderPane.qml | 2 +- src/PlainButton.qml | 2 +- src/PlainField.qml | 2 +- src/Sidebar.qml | 4 ++-- src/theme.cpp | 10 ++++++++++ src/theme.h | 2 ++ tests/folio_tests.cpp | 22 +++++++++++++++++++--- 12 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/Band.qml b/src/Band.qml index 7275034c..d9d6384f 100644 --- a/src/Band.qml +++ b/src/Band.qml @@ -81,6 +81,6 @@ MouseArea { height: Math.abs(band.end.y - band.start.y) color: Qt.alpha(theme.accent, 0.12) border.width: 1 - border.color: theme.accent + border.color: theme.accentMark } } diff --git a/src/ClashDialog.qml b/src/ClashDialog.qml index e5a02a9c..ccbaf502 100644 --- a/src/ClashDialog.qml +++ b/src/ClashDialog.qml @@ -56,7 +56,7 @@ FolioDialog { radius: Math.min(3, theme.radius) color: forAll.checked ? theme.accent : "transparent" border.width: forAll.checked ? 0 : 1 - border.color: forAll.visualFocus ? theme.accent : Qt.alpha(theme.foreground, 0.4) + border.color: forAll.visualFocus ? theme.accentMark : Qt.alpha(theme.foreground, 0.4) Glyph { anchors.centerIn: parent visible: forAll.checked diff --git a/src/FileRow.qml b/src/FileRow.qml index e0e98f9b..4a8ae017 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -69,7 +69,7 @@ Item { anchors.rightMargin: 4 radius: Math.min(4, theme.radius) border.width: folderDrop.containsDrag ? 2 : row.cursor ? 1 : 0 - border.color: row.inactive && !folderDrop.containsDrag ? Qt.alpha(theme.foreground, 0.6) : theme.accent + border.color: row.inactive && !folderDrop.containsDrag ? Qt.alpha(theme.foreground, 0.6) : theme.accentMark 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) diff --git a/src/FileTile.qml b/src/FileTile.qml index 9c965dbc..63fa875f 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -189,7 +189,7 @@ Item { : 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) && !tile.inactive || folderDrop.containsDrag ? theme.accent + strokeColor: (tile.current || tile.cursor) && !tile.inactive || folderDrop.containsDrag ? theme.accentMark : tile.current || tile.cursor ? Qt.alpha(theme.foreground, 0.6) : "transparent" strokeWidth: folderDrop.containsDrag ? 2.5 : tile.cursor ? 1 : 1.5 joinStyle: tile.iconRadius > 0 ? ShapePath.RoundJoin : ShapePath.MiterJoin @@ -276,7 +276,7 @@ Item { height: parent.shown.height + 6 color: "transparent" border.width: tile.current ? 2 : 1 - border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accent : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.6 : 0.2) + border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accentMark : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.6 : 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } @@ -300,7 +300,7 @@ Item { anchors.margins: -3 color: "transparent" border.width: tile.current ? 2 : 1 - border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accent : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.6 : 0.2) + border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accentMark : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.6 : 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } diff --git a/src/FolderDrop.qml b/src/FolderDrop.qml index 493ddbd2..8e312d88 100644 --- a/src/FolderDrop.qml +++ b/src/FolderDrop.qml @@ -31,7 +31,7 @@ FileDrop { visible: area.containsDrag color: Qt.alpha(theme.accent, 0.05) border.width: 2 - border.color: theme.accent + border.color: theme.accentMark radius: Math.min(6, theme.radius) Text { // A dragged tile's picture hangs 52 px below the pointer, so the label sits 8 px under that. diff --git a/src/FolderPane.qml b/src/FolderPane.qml index 0d73f6ca..ba00d5ce 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -459,7 +459,7 @@ FocusScope { y: pathBar.height - 1 width: parent.width height: 2 - color: pane.active ? theme.accent : "transparent" + color: pane.active ? theme.accentMark : "transparent" } // The mouse's back and forward thumb buttons move through history, as in diff --git a/src/PlainButton.qml b/src/PlainButton.qml index 72cd2132..1d999b41 100644 --- a/src/PlainButton.qml +++ b/src/PlainButton.qml @@ -40,7 +40,7 @@ AbstractButton { radius: parent.radius + 3 color: "transparent" border.width: 2 - border.color: button.destructive ? theme.red : theme.accent + border.color: button.destructive ? theme.red : theme.accentMark } } contentItem: Text { diff --git a/src/PlainField.qml b/src/PlainField.qml index eef8cd62..670fe2da 100644 --- a/src/PlainField.qml +++ b/src/PlainField.qml @@ -20,7 +20,7 @@ TextField { implicitHeight: 38 color: theme.darkBackground border.width: 1 - border.color: field.activeFocus ? theme.accent : Qt.alpha(theme.foreground, 0.18) + border.color: field.activeFocus ? theme.accentMark : Qt.alpha(theme.foreground, 0.18) radius: Math.min(4, theme.radius) } diff --git a/src/Sidebar.qml b/src/Sidebar.qml index 8f87aaf1..61550cca 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -189,7 +189,7 @@ Rectangle { : row.active ? Qt.alpha(theme.foreground, 0.10) : row.hovered && row.enabled ? Qt.alpha(theme.foreground, 0.06) : "transparent" border.width: row.dropHover ? 2 : row.keyed ? 1 : 0 - border.color: theme.accent + border.color: theme.accentMark } FileDrop { @@ -362,7 +362,7 @@ Rectangle { visible: sidebar.dragging color: Qt.alpha(theme.accent, pinDrop.containsDrag ? 0.22 : 0.08) border.width: pinDrop.containsDrag ? 2 : 1 - border.color: theme.accent + border.color: theme.accentMark radius: Math.min(4, theme.radius) Text { anchors.centerIn: parent diff --git a/src/theme.cpp b/src/theme.cpp index 5a139e7b..3ada860c 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -57,6 +57,8 @@ constexpr double midLuminance = .179; constexpr int bisectionSteps = 18; // One eight-bit channel's range, so the rounding repair can never step further. constexpr int channelRange = 255; +// WCAG's minimum contrast for marks and frames, which aren't text. +constexpr double markFloor = 3; // The ink when it already reads at need, else the least mix toward black or white that still does once rounded. QColor ensureRatio(const QColor &ink, const QColor &ground, double need) { @@ -238,3 +240,11 @@ void Theme::reload() { } } +// The accent as a thin frame or ring, raised to 3:1; fills and tints keep the accent itself. +QColor Theme::accentMark() const { + QColor mark = accent(); + for (const QColor &ground : {background(), lighterBackground(), darkBackground()}) + mark = ensureRatio(mark, ground, markFloor); + return mark; +} + diff --git a/src/theme.h b/src/theme.h index 221b9de4..a83330c4 100644 --- a/src/theme.h +++ b/src/theme.h @@ -28,6 +28,7 @@ class Theme : public QObject { Q_PROPERTY(QColor orange READ orange NOTIFY changed) Q_PROPERTY(bool light READ light NOTIFY changed) Q_PROPERTY(int radius READ radius NOTIFY changed) + Q_PROPERTY(QColor accentMark READ accentMark NOTIFY changed) public: explicit Theme(const QString ¤tDirectory = {}, QObject *parent = nullptr, const QString &hyprctl = {}); @@ -49,6 +50,7 @@ class Theme : public QObject { QColor orange() const { return color("orange"); } bool light() const { return m_light; } int radius() const { return m_radius; } + QColor accentMark() const; signals: void changed(); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index f36e51aa..a3a2a29d 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1376,6 +1376,7 @@ private slots: FileOps ops; const std::unique_ptr theme(QQmlPropertyMap::create()); theme->insert("accent", QColor(Qt::blue)); + theme->insert("accentMark", QColor(Qt::blue)); theme->insert("radius", 0); theme->insert("secondaryForeground", QColor(Qt::gray)); QQmlEngine engine; @@ -1436,8 +1437,8 @@ private slots: 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"}) + for (const char *role : {"accent", "accentMark", "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"); @@ -6499,7 +6500,7 @@ private slots: 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", + for (const char *role : {"accent", "accentMark", "foreground", "brightForeground", "secondaryForeground", "lighterBackground", "darkBackground", "background", "red", "blue", "magenta", "orange", "yellow", "cyan"}) theme->insert(role, QColor(Qt::gray)); theme->insert("radius", 0); @@ -6665,6 +6666,21 @@ private slots: QCOMPARE(passing.secondaryForeground(), blend(QColor("#1e1e2e"), QColor("#cdd6f4"), 0.68)); } + void themeFloorsAccentMarks() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), roseDawn); + Theme low(dir.path()); + for (const QColor &ground : {low.background(), low.lighterBackground(), low.darkBackground()}) + QVERIFY(contrast(low.accentMark(), ground) >= 3); + QVERIFY(snug(low.accentMark(), {low.background(), low.lighterBackground(), low.darkBackground()}, 3)); + QCOMPARE(low.accent(), QColor("#56949f")); + write(dir.filePath("theme/colors.toml"), mocha); + Theme passing(dir.path()); + QCOMPARE(passing.accentMark(), QColor("#89b4fa")); + } + 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 6463540beaba7461770742e87d20ab2351c03780 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:10:02 -0400 Subject: [PATCH 39/61] Draw the other pane's ring and disabled glyphs at 3:1 The other pane's selection ring and a disabled toolbar glyph were fixed blends that drew near 2.3:1 on Rose Pine and Catppuccin Latte. They now use quietMark and disabledForeground, raised to 3:1, and a disabled glyph stays clearly dimmer than an off toggle at 4.5:1. --- src/FileRow.qml | 2 +- src/FileTile.qml | 6 +++--- src/IconButton.qml | 2 +- src/theme.cpp | 20 ++++++++++++++++++++ src/theme.h | 4 ++++ tests/folio_tests.cpp | 35 ++++++++++++++++++++++++++++++++++- 6 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/FileRow.qml b/src/FileRow.qml index 4a8ae017..b9861957 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -69,7 +69,7 @@ Item { anchors.rightMargin: 4 radius: Math.min(4, theme.radius) border.width: folderDrop.containsDrag ? 2 : row.cursor ? 1 : 0 - border.color: row.inactive && !folderDrop.containsDrag ? Qt.alpha(theme.foreground, 0.6) : theme.accentMark + border.color: row.inactive && !folderDrop.containsDrag ? theme.quietMark : theme.accentMark 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) diff --git a/src/FileTile.qml b/src/FileTile.qml index 63fa875f..92ef61b8 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -190,7 +190,7 @@ Item { : hover.hovered ? Qt.tint(theme.lighterBackground, Qt.alpha(theme.foreground, 0.06)) : theme.lighterBackground strokeColor: (tile.current || tile.cursor) && !tile.inactive || folderDrop.containsDrag ? theme.accentMark - : tile.current || tile.cursor ? Qt.alpha(theme.foreground, 0.6) : "transparent" + : tile.current || tile.cursor ? theme.quietMark : "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) } @@ -276,7 +276,7 @@ Item { height: parent.shown.height + 6 color: "transparent" border.width: tile.current ? 2 : 1 - border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accentMark : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.6 : 0.2) + border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accentMark : tile.current || tile.cursor ? theme.quietMark : Qt.alpha(theme.foreground, 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } @@ -300,7 +300,7 @@ Item { anchors.margins: -3 color: "transparent" border.width: tile.current ? 2 : 1 - border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accentMark : Qt.alpha(theme.foreground, tile.current || tile.cursor ? 0.6 : 0.2) + border.color: (tile.current || tile.cursor) && !tile.inactive ? theme.accentMark : tile.current || tile.cursor ? theme.quietMark : Qt.alpha(theme.foreground, 0.2) radius: tile.iconRadius > 0 ? tile.iconRadius + 3 : 0 } } diff --git a/src/IconButton.qml b/src/IconButton.qml index 889521de..e98a5bf3 100644 --- a/src/IconButton.qml +++ b/src/IconButton.qml @@ -42,7 +42,7 @@ AbstractButton { anchors.centerIn: parent name: button.glyph // Opaque on the bar: a translucent stroke blends twice, so 0.35 drew at 4.0:1, next to off's 5.1:1. - color: !button.enabled ? Qt.tint(theme.background, Qt.alpha(theme.foreground, 0.5)) + color: !button.enabled ? theme.disabledForeground : button.toggle ? (button.active ? button.onInk : theme.secondaryForeground) : button.active ? theme.brightForeground : theme.foreground } diff --git a/src/theme.cpp b/src/theme.cpp index 3ada860c..bb990450 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -248,3 +248,23 @@ QColor Theme::accentMark() const { return mark; } +// The other pane's ring: the foreground at 60% where that reads on background and cards, else an opaque ink that does. +QColor Theme::quietMark() const { + // The foreground's share of the ring, as its alpha or as an opaque mix. + constexpr double share = 0.6; + const QColor bg = background(), card = lighterBackground(), fg = foreground(); + if (ratio(mix(bg, fg, share), bg) >= markFloor && ratio(mix(card, fg, share), card) >= markFloor) { + QColor ring = fg; + ring.setAlphaF(share); + return ring; + } + return ensureRatio(ensureRatio(mix(bg, fg, share), bg, markFloor), card, markFloor); +} + +// A disabled glyph: 3:1 on the bar, while the secondary ink of an off toggle keeps 4.5:1. +QColor Theme::disabledForeground() const { + // The foreground's share of a disabled glyph before the floor. + constexpr double share = 0.5; + return ensureRatio(mix(background(), foreground(), share), background(), markFloor); +} + diff --git a/src/theme.h b/src/theme.h index a83330c4..c37219e4 100644 --- a/src/theme.h +++ b/src/theme.h @@ -29,6 +29,8 @@ class Theme : public QObject { Q_PROPERTY(bool light READ light NOTIFY changed) Q_PROPERTY(int radius READ radius NOTIFY changed) Q_PROPERTY(QColor accentMark READ accentMark NOTIFY changed) + Q_PROPERTY(QColor quietMark READ quietMark NOTIFY changed) + Q_PROPERTY(QColor disabledForeground READ disabledForeground NOTIFY changed) public: explicit Theme(const QString ¤tDirectory = {}, QObject *parent = nullptr, const QString &hyprctl = {}); @@ -51,6 +53,8 @@ class Theme : public QObject { bool light() const { return m_light; } int radius() const { return m_radius; } QColor accentMark() const; + QColor quietMark() const; + QColor disabledForeground() const; signals: void changed(); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index a3a2a29d..acac0247 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6500,7 +6500,7 @@ private slots: 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", "accentMark", "foreground", "brightForeground", "secondaryForeground", "lighterBackground", + for (const char *role : {"accent", "accentMark", "quietMark", "foreground", "brightForeground", "secondaryForeground", "lighterBackground", "darkBackground", "background", "red", "blue", "magenta", "orange", "yellow", "cyan"}) theme->insert(role, QColor(Qt::gray)); theme->insert("radius", 0); @@ -6681,6 +6681,39 @@ private slots: QCOMPARE(passing.accentMark(), QColor("#89b4fa")); } + void themeFloorsTheQuietRing() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), roseDawn); + Theme low(dir.path()); + QCOMPARE(low.quietMark().alpha(), 255); + QVERIFY(contrast(low.quietMark(), low.background()) >= 3); + QVERIFY(contrast(low.quietMark(), low.lighterBackground()) >= 3); + QVERIFY(snug(low.quietMark(), {low.background(), low.lighterBackground()}, 3)); + // A theme where it reads keeps the translucent foreground, so it blends with a card as before. + write(dir.filePath("theme/colors.toml"), mocha); + Theme passing(dir.path()); + QColor ring("#cdd6f4"); + ring.setAlphaF(0.6); + QCOMPARE(passing.quietMark(), ring); + } + + void themeFloorsTheDisabledGlyph() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), roseDawn); + Theme low(dir.path()); + QVERIFY(contrast(low.disabledForeground(), low.background()) >= 3); + QVERIFY(snug(low.disabledForeground(), {low.background()}, 3)); + // Still dimmer than an off toggle. + QVERIFY(contrast(low.disabledForeground(), low.background()) < contrast(low.secondaryForeground(), low.background()) - 1); + write(dir.filePath("theme/colors.toml"), mocha); + Theme passing(dir.path()); + QCOMPARE(passing.disabledForeground(), blend(QColor("#1e1e2e"), QColor("#cdd6f4"), 0.5)); + } + 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 3aaed29055ce69533a096e4252b5040929c20065 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:10:02 -0400 Subject: [PATCH 40/61] Keep red words readable on every theme Rename problems, a plugin mark with the "error" tone and the format dialog's warning drew in the theme's red, down to 2.3:1 on Miasma. They now use redText, the red raised just enough to reach 4.5:1 on the background, while fills and the dialogs' edges keep the theme's own red. --- src/FormatDialog.qml | 2 +- src/RenameDialog.qml | 2 +- src/Sidebar.qml | 2 +- src/theme.cpp | 4 ++++ src/theme.h | 2 ++ tests/folio_tests.cpp | 15 +++++++++++++++ 6 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/FormatDialog.qml b/src/FormatDialog.qml index a51b2559..4bb60b5d 100644 --- a/src/FormatDialog.qml +++ b/src/FormatDialog.qml @@ -113,7 +113,7 @@ FolioDialog { Text { Layout.fillWidth: true text: qsTr("Everything on this drive will be erased. This can't be undone.") - color: theme.red + color: theme.redText wrapMode: Text.Wrap } RowLayout { diff --git a/src/RenameDialog.qml b/src/RenameDialog.qml index 8e8aea4c..048627f0 100644 --- a/src/RenameDialog.qml +++ b/src/RenameDialog.qml @@ -48,7 +48,7 @@ FolioDialog { horizontalAlignment: Text.AlignLeft visible: dialog.problem.length > 0 text: dialog.problem - color: theme.red + color: theme.redText wrapMode: Text.Wrap } RowLayout { diff --git a/src/Sidebar.qml b/src/Sidebar.qml index 61550cca..aab767ff 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -242,7 +242,7 @@ Rectangle { readonly property string said: row.mark ? row.mark.status : row.status visible: said.length > 0 text: said - color: !row.mark ? row.statusColor : row.mark.tone === "error" ? theme.red + color: !row.mark ? row.statusColor : row.mark.tone === "error" ? theme.redText : row.mark.tone === "warning" ? theme.yellow : theme.secondaryForeground font.pixelSize: 11 } diff --git a/src/theme.cpp b/src/theme.cpp index bb990450..5fd29448 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -268,3 +268,7 @@ QColor Theme::disabledForeground() const { return ensureRatio(mix(background(), foreground(), share), background(), markFloor); } +// Red as words, raised to 4.5:1 on the background; fills keep the theme's own red. +QColor Theme::redText() const { + return ensureRatio(red(), background(), textFloor); +} diff --git a/src/theme.h b/src/theme.h index c37219e4..f359b765 100644 --- a/src/theme.h +++ b/src/theme.h @@ -31,6 +31,7 @@ class Theme : public QObject { Q_PROPERTY(QColor accentMark READ accentMark NOTIFY changed) Q_PROPERTY(QColor quietMark READ quietMark NOTIFY changed) Q_PROPERTY(QColor disabledForeground READ disabledForeground NOTIFY changed) + Q_PROPERTY(QColor redText READ redText NOTIFY changed) public: explicit Theme(const QString ¤tDirectory = {}, QObject *parent = nullptr, const QString &hyprctl = {}); @@ -55,6 +56,7 @@ class Theme : public QObject { QColor accentMark() const; QColor quietMark() const; QColor disabledForeground() const; + QColor redText() const; signals: void changed(); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index acac0247..2efc2f90 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6714,6 +6714,21 @@ private slots: QCOMPARE(passing.disabledForeground(), blend(QColor("#1e1e2e"), QColor("#cdd6f4"), 0.5)); } + void themeFloorsRedText() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), roseDawn); + Theme low(dir.path()); + QVERIFY(contrast(low.redText(), low.background()) >= 4.5); + QVERIFY(snug(low.redText(), {low.background()}, 4.5)); + // Fills and the confirm card's edge keep the theme's own red. + QCOMPARE(low.red(), QColor("#b4637a")); + write(dir.filePath("theme/colors.toml"), mocha); + Theme passing(dir.path()); + QCOMPARE(passing.redText(), QColor("#f38ba8")); + } + 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 f603960ea4c10328f5a7ef41ac97a0575825751c Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:10:03 -0400 Subject: [PATCH 41/61] Draw accent words at 4.5:1 on their ground Drop to pin and the preview window's file extension drew in the raw accent, down to 2.5:1 on Rose Pine over the pin box's tint. They now use accentText, the accent raised just enough to reach 4.5:1 on the background, the preview window and that tint, while fills and marks keep their colours. --- src/PreviewWindow.qml | 2 +- src/Sidebar.qml | 3 ++- src/theme.cpp | 11 +++++++++++ src/theme.h | 2 ++ tests/folio_tests.cpp | 16 ++++++++++++++++ 5 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/PreviewWindow.qml b/src/PreviewWindow.qml index cf493eab..109ff2db 100644 --- a/src/PreviewWindow.qml +++ b/src/PreviewWindow.qml @@ -383,7 +383,7 @@ ApplicationWindow { Text { Layout.alignment: Qt.AlignHCenter text: win.item.extension || "" - color: theme.accent + color: theme.accentText font.weight: Font.DemiBold } Text { diff --git a/src/Sidebar.qml b/src/Sidebar.qml index aab767ff..b6d9c31b 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -360,6 +360,7 @@ Rectangle { Layout.fillWidth: true implicitHeight: 34 visible: sidebar.dragging + // Theme's accentText floors on this 0.22 tint, so the two change together. color: Qt.alpha(theme.accent, pinDrop.containsDrag ? 0.22 : 0.08) border.width: pinDrop.containsDrag ? 2 : 1 border.color: theme.accentMark @@ -367,7 +368,7 @@ Rectangle { Text { anchors.centerIn: parent text: qsTr("Drop to pin") - color: theme.accent + color: theme.accentText font.pixelSize: 12 } DropArea { diff --git a/src/theme.cpp b/src/theme.cpp index 5fd29448..539df2ac 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -59,6 +59,8 @@ constexpr int bisectionSteps = 18; constexpr int channelRange = 255; // WCAG's minimum contrast for marks and frames, which aren't text. constexpr double markFloor = 3; +// The pin box's accent tint in Sidebar.qml while a drop hovers it, the strongest ground under its words. +constexpr double strongestPinTint = 0.22; // The ink when it already reads at need, else the least mix toward black or white that still does once rounded. QColor ensureRatio(const QColor &ink, const QColor &ground, double need) { @@ -272,3 +274,12 @@ QColor Theme::disabledForeground() const { QColor Theme::redText() const { return ensureRatio(red(), background(), textFloor); } + +// The accent as words, raised to textFloor on the background, the preview window and the pin box's strongest tint. +QColor Theme::accentText() const { + const QColor bg = background(), tint = mix(bg, accent(), strongestPinTint); + QColor ink = accent(); + for (const QColor &ground : {bg, darkBackground(), tint}) + ink = ensureRatio(ink, ground, textFloor); + return ink; +} diff --git a/src/theme.h b/src/theme.h index f359b765..caad2316 100644 --- a/src/theme.h +++ b/src/theme.h @@ -32,6 +32,7 @@ class Theme : public QObject { Q_PROPERTY(QColor quietMark READ quietMark NOTIFY changed) Q_PROPERTY(QColor disabledForeground READ disabledForeground NOTIFY changed) Q_PROPERTY(QColor redText READ redText NOTIFY changed) + Q_PROPERTY(QColor accentText READ accentText NOTIFY changed) public: explicit Theme(const QString ¤tDirectory = {}, QObject *parent = nullptr, const QString &hyprctl = {}); @@ -57,6 +58,7 @@ class Theme : public QObject { QColor quietMark() const; QColor disabledForeground() const; QColor redText() const; + QColor accentText() const; signals: void changed(); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 2efc2f90..99ca41a0 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6729,6 +6729,22 @@ private slots: QCOMPARE(passing.redText(), QColor("#f38ba8")); } + void themeFloorsAccentText() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), roseDawn); + Theme low(dir.path()); + const QColor pinTint = blend(low.background(), low.accent(), 0.22); + for (const QColor &ground : {low.background(), low.darkBackground(), pinTint}) + QVERIFY(contrast(low.accentText(), ground) >= 4.5); + QVERIFY(snug(low.accentText(), {low.background(), low.darkBackground(), pinTint}, 4.5)); + QCOMPARE(low.accent(), QColor("#56949f")); + write(dir.filePath("theme/colors.toml"), mocha); + Theme passing(dir.path()); + QCOMPARE(passing.accentText(), QColor("#89b4fa")); + } + 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 11a6f08599168f0c6bc7ecfad3df113e96262455 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:10:03 -0400 Subject: [PATCH 42/61] Draw a plugin mark's warning status at 4.5:1 A plugin mark with the "warning" tone (Dropbox syncing) drew its status in the theme's yellow, down to 2.1:1 on Rose Pine and 2.3:1 on Catppuccin Latte and Flexoki Light. It now uses yellowText, the yellow raised just enough to reach 4.5:1 on the background. --- src/Sidebar.qml | 2 +- src/theme.cpp | 5 +++++ src/theme.h | 2 ++ tests/folio_tests.cpp | 15 +++++++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Sidebar.qml b/src/Sidebar.qml index b6d9c31b..f32ec1bb 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -243,7 +243,7 @@ Rectangle { visible: said.length > 0 text: said color: !row.mark ? row.statusColor : row.mark.tone === "error" ? theme.redText - : row.mark.tone === "warning" ? theme.yellow : theme.secondaryForeground + : row.mark.tone === "warning" ? theme.yellowText : 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/theme.cpp b/src/theme.cpp index 539df2ac..08387c23 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -283,3 +283,8 @@ QColor Theme::accentText() const { ink = ensureRatio(ink, ground, textFloor); return ink; } + +// Yellow as words (a plugin mark's "warning" tone), raised to 4.5:1 on the background. +QColor Theme::yellowText() const { + return ensureRatio(yellow(), background(), textFloor); +} diff --git a/src/theme.h b/src/theme.h index caad2316..accfefb1 100644 --- a/src/theme.h +++ b/src/theme.h @@ -33,6 +33,7 @@ class Theme : public QObject { Q_PROPERTY(QColor disabledForeground READ disabledForeground NOTIFY changed) Q_PROPERTY(QColor redText READ redText NOTIFY changed) Q_PROPERTY(QColor accentText READ accentText NOTIFY changed) + Q_PROPERTY(QColor yellowText READ yellowText NOTIFY changed) public: explicit Theme(const QString ¤tDirectory = {}, QObject *parent = nullptr, const QString &hyprctl = {}); @@ -59,6 +60,7 @@ class Theme : public QObject { QColor disabledForeground() const; QColor redText() const; QColor accentText() const; + QColor yellowText() const; signals: void changed(); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 99ca41a0..42804c86 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6745,6 +6745,21 @@ private slots: QCOMPARE(passing.accentText(), QColor("#89b4fa")); } + void themeFloorsYellowText() + { + // Rose Pine Dawn's gold is 2.1:1 on its background; Catppuccin Mocha's yellow already reads. + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), QByteArray(roseDawn) + "yellow = \"#ea9d34\"\n"); + Theme low(dir.path()); + QVERIFY(contrast(low.yellowText(), low.background()) >= 4.5); + QVERIFY(snug(low.yellowText(), {low.background()}, 4.5)); + QCOMPARE(low.yellow(), QColor("#ea9d34")); + write(dir.filePath("theme/colors.toml"), QByteArray(mocha) + "yellow = \"#f9e2af\"\n"); + Theme passing(dir.path()); + QCOMPARE(passing.yellowText(), QColor("#f9e2af")); + } + 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 e28b1aac0e42f5dc11697cc8b8ffad188cf3e715 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:10:03 -0400 Subject: [PATCH 43/61] Write a grey theme red in the foreground Vantablack, Solitude and White set red to a grey, so a rename problem, a Dropbox that is not running or the format warning drew in a grey that does not read as red. When the red's saturation is 0.2 or less those words take the foreground; fills and the dialogs' edges keep the colour. --- src/theme.cpp | 6 ++++-- tests/folio_tests.cpp | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/theme.cpp b/src/theme.cpp index 08387c23..877428f9 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -61,6 +61,8 @@ constexpr int channelRange = 255; constexpr double markFloor = 3; // The pin box's accent tint in Sidebar.qml while a drop hovers it, the strongest ground under its words. constexpr double strongestPinTint = 0.22; +// At or under this saturation a theme's red is a grey. +constexpr double greyRedSaturation = 0.2; // The ink when it already reads at need, else the least mix toward black or white that still does once rounded. QColor ensureRatio(const QColor &ink, const QColor &ground, double need) { @@ -270,9 +272,9 @@ QColor Theme::disabledForeground() const { return ensureRatio(mix(background(), foreground(), share), background(), markFloor); } -// Red as words, raised to 4.5:1 on the background; fills keep the theme's own red. +// Red as words, raised to textFloor, or the foreground when the theme's red is a grey; fills keep the theme's red. QColor Theme::redText() const { - return ensureRatio(red(), background(), textFloor); + return red().hsvSaturationF() <= greyRedSaturation ? foreground() : ensureRatio(red(), background(), textFloor); } // The accent as words, raised to textFloor on the background, the preview window and the pin box's strongest tint. diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 42804c86..64a352b9 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6760,6 +6760,26 @@ private slots: QCOMPARE(passing.yellowText(), QColor("#f9e2af")); } + void themeWritesAGreyRedInTheForeground() + { + // Vantablack's red is a grey that reads at 8.4:1, Solitude's a grey at 2.8:1; both write in the foreground. + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), "background = \"#000000\"\nforeground = \"#ffffff\"\nred = \"#a4a4a4\"\n"); + Theme vantablack(dir.path()); + QCOMPARE(vantablack.redText(), QColor("#ffffff")); + QCOMPARE(vantablack.red(), QColor("#a4a4a4")); + write(dir.filePath("theme/colors.toml"), "background = \"#101315\"\nforeground = \"#cacccc\"\nred = \"#565d60\"\n"); + Theme solitude(dir.path()); + QCOMPARE(solitude.redText(), QColor("#cacccc")); + // A red with colour keeps the floor: Miasma's #685742 (saturation 0.37) is raised, not replaced. + write(dir.filePath("theme/colors.toml"), "background = \"#222222\"\nforeground = \"#c2c2b0\"\nred = \"#685742\"\n"); + Theme miasma(dir.path()); + QVERIFY(miasma.redText() != miasma.foreground()); + QVERIFY(contrast(miasma.redText(), miasma.background()) >= 4.5); + QVERIFY(snug(miasma.redText(), {miasma.background()}, 4.5)); + } + 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 4bcd81f1cfd958137c3c61d9a875a68f5cea9b6e Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:10:03 -0400 Subject: [PATCH 44/61] Write tile badges in an ink that reads on their color A page stand-in's badge wrote its extension in the background color on the kind color, down to 2.1:1 on Rose Pine's yellow. It now takes the background where that reads at 4.5:1, else the foreground, else whichever reads better raised to 4.5:1, and the badge keeps the theme's own kind color. --- src/FileTile.qml | 12 +++++++- src/theme.cpp | 8 +++++ src/theme.h | 1 + tests/folio_tests.cpp | 69 +++++++++++++++++++++++++++++++++++++++---- 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/FileTile.qml b/src/FileTile.qml index 92ef61b8..512c085e 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -336,7 +336,17 @@ Item { width: badge.implicitWidth + 8 height: badge.implicitHeight + 2 color: tile.kindColor - Text { id: badge; anchors.centerIn: parent; text: tile.extension.slice(0, 4); font.pixelSize: 9; font.bold: true; color: theme.background } + Text { + id: badge + anchors.centerIn: parent + text: tile.extension.slice(0, 4) + font.pixelSize: 9 + font.bold: true + color: { + theme.background // Re-read when the theme changes, not only the fill. + return theme.inkOn(tile.kindColor) + } + } } } } diff --git a/src/theme.cpp b/src/theme.cpp index 877428f9..d6058ccb 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -290,3 +290,11 @@ QColor Theme::accentText() const { QColor Theme::yellowText() const { return ensureRatio(yellow(), background(), textFloor); } + +// Words on a palette fill (a tile's badge): the background where it reads, else the better of it and the foreground. +QColor Theme::inkOn(const QColor &fill) const { + const QColor bg = background(), fg = foreground(); + if (ratio(bg, fill) >= textFloor) + return bg; + return ensureRatio(ratio(bg, fill) >= ratio(fg, fill) ? bg : fg, fill, textFloor); +} diff --git a/src/theme.h b/src/theme.h index accfefb1..0285fa78 100644 --- a/src/theme.h +++ b/src/theme.h @@ -61,6 +61,7 @@ class Theme : public QObject { QColor redText() const; QColor accentText() const; QColor yellowText() const; + Q_INVOKABLE QColor inkOn(const QColor &fill) const; signals: void changed(); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 64a352b9..7104ccfe 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6495,21 +6495,18 @@ private slots: void searchResultTileKeepsAPercentInItsFolder() { // A "%2" in a result's folder stays as written, not taken for the size and date's place. + QTemporaryDir home; + Theme theme(home.filePath("theme")); 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", "accentMark", "quietMark", "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("theme", &theme); engine.rootContext()->setContextProperty("translator", translator.get()); engine.rootContext()->setContextProperty("backend", backend.get()); QQmlComponent component(&engine); @@ -6780,6 +6777,66 @@ private slots: QVERIFY(snug(miasma.redText(), {miasma.background()}, 4.5)); } + void themeWritesBadgesThatReadOnEveryKindColor() + { + // Every stock Omarchy theme (quattro at ceb30ed, plus osaka-jade): its kind colors and the "other" kind's ink. + const char *palettes[] = { + "background=#1e1e2e foreground=#cdd6f4 red=#f38ba8 blue=#89b4fa magenta=#f5c2e7 orange=#f6b6ab yellow=#f9e2af cyan=#94e2d5", // catppuccin + "background=#eff1f5 foreground=#4c4f69 red=#d20f39 blue=#1e66f5 magenta=#ea76cb orange=#d84e2b yellow=#df8e1d cyan=#179299", // catppuccin-latte + "background=#060B1E foreground=#ffcead red=#ED5B5A blue=#7d82d9 magenta=#c89dc1 orange=#eb8b54 yellow=#E9BB4F cyan=#a3bfd1", // ethereal + "background=#2d353b foreground=#d3c6aa red=#e67e80 blue=#7fbbb3 magenta=#d699b6 orange=#e09d7f yellow=#dbbc7f cyan=#83c092", // everforest + "background=#FFFCF0 foreground=#100F0F red=#D14D41 blue=#205EA6 magenta=#CE5D97 orange=#d0772b yellow=#D0A215 cyan=#3AA99F", // flexoki-light + "background=#282828 foreground=#d4be98 red=#ea6962 blue=#7daea3 magenta=#d3869b orange=#e1875c yellow=#d8a657 cyan=#89b482", // gruvbox + "background=#0B0C16 foreground=#ddf7ff red=#50f872 blue=#829dd4 magenta=#86a7df orange=#50f7a3 yellow=#50f7d4 cyan=#7cf8f7", // hackerman + "background=#142620 foreground=#dfede5 red=#d88d91 blue=#96b7d0 magenta=#b7a1c8 orange=#d2ab8d yellow=#c5cc91 cyan=#86c7c5", // jade-horse + "background=#1f1f28 foreground=#dcd7ba red=#c34043 blue=#7e9cd8 magenta=#957fb8 orange=#c17158 yellow=#c0a36e cyan=#6a9589", // kanagawa + "background=#0c0b0c foreground=#FAFCFB red=#c38b7b blue=#b59790 magenta=#c4d8e2 yellow=#6B5E73 cyan=#a5a0b6", // last-horizon + "background=#16242d foreground=#d6e2ee red=#4d86b0 blue=#6fb8e3 magenta=#8bc9eb orange=#8bc9eb yellow=#6fa4c9 cyan=#b4e4f6", // lumon + "background=#fafafa foreground=#212121 red=#c900c4 blue=#3264eb magenta=#8a4ad7 orange=#026fde yellow=#026fde cyan=#0c67de", // lupine + "background=#121212 foreground=#bebebe red=#D35F5F blue=#e68e0d magenta=#D35F5F orange=#c63d3d yellow=#b91c1c cyan=#bebebe", // matte-black + "background=#222222 foreground=#c2c2b0 red=#685742 blue=#78824b magenta=#bb7744 orange=#8d6242 yellow=#b36d43 cyan=#c9a554", // miasma + "background=#2e3440 foreground=#d8dee9 red=#bf616a blue=#81a1c1 magenta=#b48ead orange=#d5967a yellow=#ebcb8b cyan=#88c0d0", // nord + "background=#111c18 foreground=#C1C497 red=#FF5345 blue=#509475 magenta=#D2689C orange=#a2734b yellow=#459451 cyan=#2DD5B7", // osaka-jade + "background=#05182e foreground=#f6dcac red=#f85525 blue=#3f8f8a magenta=#3f8f8a orange=#faa968 yellow=#e97b3c cyan=#8cbfb8", // retro-82 + "background=#2c2525 foreground=#e6d9db red=#fd6883 blue=#f38d70 magenta=#a8a9eb orange=#fb9a77 yellow=#f9cc6c cyan=#85dacc", // ristretto + "background=#faf4ed foreground=#575279 red=#b4637a blue=#56949f magenta=#907aa9 orange=#cf8057 yellow=#ea9d34 cyan=#d7827e", // rose-pine + "background=#101315 foreground=#cacccc red=#565d60 blue=#798186 magenta=#aeaeae yellow=#d9dbdc cyan=#707070", // solitude + "background=#1c1c1e foreground=#f5f5f7 red=#ff5f4a blue=#8fb8de magenta=#ff8fa3 orange=#ff6a3d yellow=#ffc27a cyan=#a6dcef", // starship + "background=#1a1b26 foreground=#a9b1d6 red=#f7768e blue=#7aa2f7 magenta=#ad8ee6 orange=#eb927b yellow=#e0af68 cyan=#449dab", // tokyo-night + "background=#000000 foreground=#ffffff red=#a4a4a4 blue=#8d8d8d magenta=#9b9b9b orange=#b9b9b9 yellow=#cecece cyan=#b0b0b0", // vantablack + "background=#ffffff foreground=#000000 red=#2a2a2a blue=#1a1a1a magenta=#2e2e2e yellow=#4a4a4a cyan=#3e3e3e", // white + }; + // Flexoki Light's yellow is one fill where the background fails and the foreground reads; Rose Pine's needs a raised ink. + int foregroundInks = 0, raisedInks = 0; + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + for (const char *palette : palettes) { + QByteArray toml; + for (const QByteArray &pair : QByteArray(palette).split(' ')) { + const QList parts = pair.split('='); + toml += parts[0] + " = \"" + parts[1] + "\"\n"; + } + write(dir.filePath("theme/colors.toml"), toml); + Theme theme(dir.path()); + for (const QColor &fill : {theme.red(), theme.blue(), theme.magenta(), theme.orange(), theme.yellow(), + theme.cyan(), theme.secondaryForeground()}) { + const QColor ink = theme.inkOn(fill); + QVERIFY2(contrast(ink, fill) >= 4.5, palette); + if (contrast(theme.background(), fill) >= 4.5) { + QCOMPARE(ink, theme.background()); + } else if (contrast(theme.foreground(), fill) >= 4.5) { + QCOMPARE(ink, theme.foreground()); + ++foregroundInks; + } else { + QVERIFY2(snug(ink, {fill}, 4.5), palette); + ++raisedInks; + } + } + } + QVERIFY(foregroundInks > 0); + QVERIFY(raisedInks > 0); + } + 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 25295736555f3da7099145df9e4daaf085320c60 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:14:47 -0400 Subject: [PATCH 45/61] Draw the details and split glyphs as two things Side by side in the top bar, the details glyph and the split glyph were both a box with one upright bar. Details now holds two short lines in its side panel, and split's two boxes stand 4 units apart. --- src/Glyph.qml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Glyph.qml b/src/Glyph.qml index b16c3cfb..9bc4a460 100644 --- a/src/Glyph.qml +++ b/src/Glyph.qml @@ -72,8 +72,9 @@ Item { // Bulleted, so it reads as a list of files rather than a menu. case "list": return "M9 6h11M9 12h11M9 18h11M4 6h1M4 12h1M4 18h1" case "columns": return box(3, 4, 18, 16, r) + "M9 4v16M15 4v16" - case "preview": return box(3, 5, 18, 14, r) + "M14 5v14" - case "split": return box(3, 5, 8, 14, r / 2) + box(13, 5, 8, 14, r / 2) + // Facts in a side panel, against split's two bare boxes with a gap between them. + case "preview": return box(3, 5, 18, 14, r) + "M14 5v14M16.5 9h2M16.5 12h2" + case "split": return box(3, 5, 7, 14, r / 2) + box(14, 5, 7, 14, r / 2) // One per sort order: a slim arrow on the left (down for the usual // direction, up for oldest first), and what it sorts by on the right, // kept apart so both read at toolbar size. From ff6c0e9e3eac250695c11ee11e17f8c6eb9da1fc Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:14:57 -0400 Subject: [PATCH 46/61] Draw an archive tile as the box its row draws In the grid an archive drew the ruled page every text file draws, while its list row drew the lidded box. The page now holds the archive glyph in the kind's colour instead of the lines; the extension badge stays. --- src/FileTile.qml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/FileTile.qml b/src/FileTile.qml index 512c085e..03f29ca3 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -319,6 +319,7 @@ Item { border.color: Qt.alpha(theme.foreground, 0.16) radius: tile.iconRadius Column { + visible: tile.kind !== "archive" x: 9; y: 11 spacing: 6 Rectangle { width: 34; height: 4; color: tile.kindColor } @@ -327,6 +328,8 @@ Item { Rectangle { required property int modelData; width: modelData; height: 2; color: Qt.alpha(theme.foreground, 0.3) } } } + // An archive is the lidded box its row draws, not a page of text. + Glyph { anchors.centerIn: parent; visible: tile.kind === "archive"; name: "archive"; size: 32; color: tile.kindColor } } Rectangle { visible: tile.extension.length > 0 From cfecb015fce5c6c1165a510344b02f0e29fc8917 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:15:21 -0400 Subject: [PATCH 47/61] Say where it searched when nothing was found With no results the field hint read "0 found in X and below", which looks like a counter beside the pane's "Nothing matches". At no results it now says "Searched X and below", and the pane gives the result. --- src/PathBar.qml | 1 + src/i18n/en.json | 1 + 2 files changed, 2 insertions(+) diff --git a/src/PathBar.qml b/src/PathBar.qml index ecdddb7b..aadcb3f2 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -124,6 +124,7 @@ Rectangle { anchors.verticalCenter: parent.verticalCenter text: backend.path.startsWith("plugin:") ? qsTr("Search %1").arg(here) : !settled ? qsTr("Search %1 and below").arg(here) + : backend.folder.count === 0 ? qsTr("Searched %1 and below").arg(here) : backend.folder.count >= backend.folder.searchLimit ? qsTr("%Ln or more found in %1 and below", "", backend.folder.count).arg(here) : qsTr("%Ln found in %1 and below", "", backend.folder.count).arg(here) color: theme.secondaryForeground diff --git a/src/i18n/en.json b/src/i18n/en.json index a78bd069..33227db4 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -371,6 +371,7 @@ "Search %1": "Search %1", "Search %1 and below": "Search %1 and below", "Search this folder and below": "Search this folder and below", + "Searched %1 and below": "Searched %1 and below", "Select All": "Select All", "Select a range": "Select a range", "Select everything": "Select everything", From a2fe31fa145bbc9c1a438794c390029d237ff87a Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:15:21 -0400 Subject: [PATCH 48/61] Draw the drop verb in the foreground ink "Move here" and "Copy here" on the drop frame, a list or column row and a sidebar row drew in secondary ink on the accent tint, 4.45:1, under the 4.5:1 text floor. All three now draw in the theme's foreground. --- src/FileRow.qml | 2 +- src/FolderDrop.qml | 2 +- src/Sidebar.qml | 2 +- tests/folio_tests.cpp | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/FileRow.qml b/src/FileRow.qml index b9861957..76497843 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -161,7 +161,7 @@ Item { Text { visible: folderDrop.containsDrag text: folderDrop.copies ? qsTr("Copy here") : qsTr("Move here") - color: theme.secondaryForeground + color: theme.foreground font.pixelSize: 12 } // Points to where the next column opens. diff --git a/src/FolderDrop.qml b/src/FolderDrop.qml index 8e312d88..2ce80b87 100644 --- a/src/FolderDrop.qml +++ b/src/FolderDrop.qml @@ -40,7 +40,7 @@ FileDrop { x: Math.max(edgeMargin, Math.min(parent.width - width - edgeMargin, area.drag.x - parent.x - width / 2)) y: Math.max(edgeMargin, Math.min(parent.height - height - edgeMargin, area.drag.y - parent.y + belowPointer)) text: area.copies ? qsTr("Copy here") : qsTr("Move here") - color: theme.secondaryForeground + color: theme.foreground font.pixelSize: 12 } } diff --git a/src/Sidebar.qml b/src/Sidebar.qml index f32ec1bb..e7e1cdfc 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -246,7 +246,7 @@ Rectangle { : row.mark.tone === "warning" ? theme.yellowText : 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 } + Text { visible: rowDrop.containsDrag && !row.dropTrashes; text: rowDrop.copies ? qsTr("Copy here") : qsTr("Move here"); color: theme.foreground; font.pixelSize: 11 } } } diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 7104ccfe..b9181a0b 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1378,7 +1378,7 @@ private slots: theme->insert("accent", QColor(Qt::blue)); theme->insert("accentMark", QColor(Qt::blue)); theme->insert("radius", 0); - theme->insert("secondaryForeground", QColor(Qt::gray)); + theme->insert("foreground", QColor(Qt::gray)); QQmlEngine engine; engine.rootContext()->setContextProperty("fileOps", &ops); engine.rootContext()->setContextProperty("theme", theme.get()); From 8c70a6bfce56c157f2bade1b70f22f8c31f1f21f Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:16:07 -0400 Subject: [PATCH 49/61] Light the part of a result's name that matched A search result named the file with nothing to show why it was found. The run that matched, found as the search finds it (ignoring case), now draws in bright ink in the grid, list and columns; the rest stays as it was. --- src/FileRow.qml | 6 +++++- src/FileTile.qml | 5 ++++- src/Format.js | 8 ++++++++ src/foldermodel.cpp | 4 ++++ src/foldermodel.h | 2 ++ tests/folio_tests.cpp | 41 +++++++++++++++++++++++++++++++++++------ 6 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/FileRow.qml b/src/FileRow.qml index 76497843..3fb89542 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -120,10 +120,14 @@ Item { // mirrored: left as they are, they'd follow their own script, and a // Latin name would sit at the far end of a right-to-left row. Text { + // A search result lights the run of its name that matched; only the folder being shown is searched. + readonly property string lit: row.ListView.view.model === backend.folder + ? Format.lit(row.fileName, backend.folder.matchStart(row.fileName), backend.folder.query.length, theme.brightForeground) : "" Layout.fillWidth: true Layout.minimumWidth: 120 horizontalAlignment: Text.AlignLeft - text: row.fileName + text: lit || row.fileName + textFormat: lit ? Text.StyledText : Text.AutoText color: row.current ? theme.brightForeground : theme.foreground elide: Text.ElideMiddle } diff --git a/src/FileTile.qml b/src/FileTile.qml index 03f29ca3..ff7a447c 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -75,11 +75,14 @@ Item { Text { id: label + // A search result lights the run of its name that matched. + readonly property string lit: Format.lit(tile.fileName, backend.folder.matchStart(tile.fileName), backend.folder.query.length, theme.brightForeground) anchors.top: artBox.bottom anchors.topMargin: 12 anchors.horizontalCenter: parent.horizontalCenter width: parent.width - 28 - text: tile.fileName + text: lit || tile.fileName + textFormat: lit ? Text.StyledText : Text.AutoText color: tile.current ? theme.brightForeground : theme.foreground font.pixelSize: 12 horizontalAlignment: Text.AlignHCenter diff --git a/src/Format.js b/src/Format.js index 740ffd94..6cfd2822 100644 --- a/src/Format.js +++ b/src/Format.js @@ -48,6 +48,14 @@ function count(n) { return n === 0 ? qsTr("No items") : n > 0 ? qsTr("%Ln items", "", n) : "" } +// A search result's name as StyledText, its matched run in `color`; "" for no match or for white space StyledText folds. +function lit(name, start, length, color) { + if (start < 0 || /^\s|\s$|\s\s|[^\S ]|\u0085/.test(name)) + return "" + const escaped = text => text.replace(/&/g, "&").replace(//g, ">") + return escaped(name.slice(0, start)) + "" + escaped(name.slice(start, start + length)) + "" + escaped(name.slice(start + length)) +} + // What an item is and how big, under its name in the details and the // preview; for a plugin's item, its subtitle. function summary(item, type) { diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index ed843f42..18152116 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -653,6 +653,10 @@ int FolderModel::indexOfPath(const QString &path) const { return -1; } +int FolderModel::matchStart(const QString &fileName) const { + return m_query.isEmpty() ? -1 : fileName.indexOf(m_query, 0, Qt::CaseInsensitive); +} + void FolderModel::setLoading(bool loading) { if (loading == m_loading) return; diff --git a/src/foldermodel.h b/src/foldermodel.h index c0175778..0aabe638 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -101,6 +101,8 @@ class FolderModel : public QAbstractListModel { // By path, for Recents, where two files from different folders can // share a name. Q_INVOKABLE int indexOfPath(const QString &path) const; + // Where the query starts in a file's name, matched as the search matches it; -1 for none. + Q_INVOKABLE int matchStart(const QString &fileName) const; // The selection, kept by path so it survives a re-read or a re-sort. // The views keep the cursor (the current item) themselves. diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index b9181a0b..89c4b1a4 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1436,14 +1436,14 @@ private slots: 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()); + // A real model, whose matchStart a search result's name calls. + FolderModel folder; for (const char *role : {"accent", "accentMark", "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())); + backend->insert("folder", QVariant::fromValue(&folder)); QQmlEngine engine; engine.rootContext()->setContextProperty("fileOps", &ops); engine.rootContext()->setContextProperty("theme", theme.get()); @@ -6500,10 +6500,9 @@ private slots: FileOps ops; const std::unique_ptr translator(QQmlPropertyMap::create()); const std::unique_ptr backend(QQmlPropertyMap::create()); - const std::unique_ptr folder(QQmlPropertyMap::create()); + FolderModel folder; translator->insert("language", "en"); - folder->insert("selectedCount", 0); - backend->insert("folder", QVariant::fromValue(folder.get())); + backend->insert("folder", QVariant::fromValue(&folder)); QQmlEngine engine; engine.rootContext()->setContextProperty("fileOps", &ops); engine.rootContext()->setContextProperty("theme", &theme); @@ -6837,6 +6836,36 @@ private slots: QVERIFY(raisedInks > 0); } + void folderMatchStartIsWhereTheSearchMatched() + { + QTemporaryDir dir; + QDir(dir.path()).mkpath("Shots"); + write(dir.filePath("Field-NOTES.md"), "x"); + write(dir.filePath("Shots/notes and notes.txt"), "x"); + write(dir.filePath(QString::fromUtf8("\u00c9T\u00c9 notes.txt")), "x"); + write(dir.filePath("plan.txt"), "x"); + + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(dir.path()); + QVERIFY(loaded.wait(3000)); + // Nothing is lit until there is a query. + QCOMPARE(model.matchStart("Field-NOTES.md"), -1); + model.setQuery("Notes"); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 3); + // Every result lights where the search found it, ignoring case, at the first match. + for (int row = 0; row < model.count(); ++row) + QVERIFY(model.matchStart(model.get(row).value("fileName").toString()) >= 0); + QCOMPARE(model.matchStart("Field-NOTES.md"), 6); + QCOMPARE(model.matchStart("notes and notes.txt"), 0); + QCOMPARE(model.matchStart("plan.txt"), -1); + model.setQuery(QString::fromUtf8("\u00e9t\u00e9")); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 1); + QCOMPARE(model.matchStart(QString::fromUtf8("\u00c9T\u00c9 notes.txt")), 0); + } + 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 21cbaf4a2b7202b3905f33289099d87bb5cf981e Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:23:09 -0400 Subject: [PATCH 50/61] Draw file names as plain text A file named like markup, such as "x.txt", drew as rich text, bold and without its tags, because name labels took Text's AutoText default. Every label that shows a file, folder or drive name now draws plain text. --- src/FileRow.qml | 3 ++- src/FileTile.qml | 6 ++++-- src/FolderGrid.qml | 1 + src/FolderList.qml | 1 + src/FolderPane.qml | 1 + src/FolioDialog.qml | 1 + src/FormatDialog.qml | 2 ++ src/MediaPreview.qml | 1 + src/PathBar.qml | 2 ++ src/PreviewPane.qml | 3 +++ src/PreviewWindow.qml | 2 ++ src/RenameDialog.qml | 1 + src/Sidebar.qml | 1 + 13 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/FileRow.qml b/src/FileRow.qml index 3fb89542..1f9ba8dd 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -127,7 +127,7 @@ Item { Layout.minimumWidth: 120 horizontalAlignment: Text.AlignLeft text: lit || row.fileName - textFormat: lit ? Text.StyledText : Text.AutoText + textFormat: lit ? Text.StyledText : Text.PlainText color: row.current ? theme.brightForeground : theme.foreground elide: Text.ElideMiddle } @@ -136,6 +136,7 @@ Item { Layout.maximumWidth: Math.round(row.width / 3) horizontalAlignment: Text.AlignLeft text: row.folder + textFormat: Text.PlainText color: theme.secondaryForeground font.pixelSize: 12 elide: Text.ElideMiddle diff --git a/src/FileTile.qml b/src/FileTile.qml index ff7a447c..16ecef1f 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -82,7 +82,7 @@ Item { anchors.horizontalCenter: parent.horizontalCenter width: parent.width - 28 text: lit || tile.fileName - textFormat: lit ? Text.StyledText : Text.AutoText + textFormat: lit ? Text.StyledText : Text.PlainText color: tile.current ? theme.brightForeground : theme.foreground font.pixelSize: 12 horizontalAlignment: Text.AlignHCenter @@ -114,6 +114,7 @@ Item { // 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 } + textFormat: Text.PlainText color: tile.current ? theme.foreground : theme.secondaryForeground font.pixelSize: 10 horizontalAlignment: Text.AlignHCenter @@ -346,6 +347,7 @@ Item { id: badge anchors.centerIn: parent text: tile.extension.slice(0, 4) + textFormat: Text.PlainText font.pixelSize: 9 font.bold: true color: { @@ -383,7 +385,7 @@ Item { } } } - Text { anchors.right: parent.right; anchors.bottom: parent.bottom; anchors.margins: 5; text: tile.extension; font.pixelSize: 9; color: theme.secondaryForeground } + Text { anchors.right: parent.right; anchors.bottom: parent.bottom; anchors.margins: 5; text: tile.extension; textFormat: Text.PlainText; font.pixelSize: 9; color: theme.secondaryForeground } } } diff --git a/src/FolderGrid.qml b/src/FolderGrid.qml index f6df0f98..e6ad0e10 100644 --- a/src/FolderGrid.qml +++ b/src/FolderGrid.qml @@ -111,6 +111,7 @@ GridView { visible: grid.count === 0 && !backend.folder.loading && backend.folder.error.length === 0 text: backend.folder.query ? qsTr("Nothing matches “%1”").arg(backend.folder.query) : plugins.views[backend.path] ? plugins.views[backend.path].empty : qsTr("This folder is empty") + textFormat: Text.PlainText color: theme.secondaryForeground // The way out of a search that found nothing. Text { diff --git a/src/FolderList.qml b/src/FolderList.qml index f275ac34..3f36172a 100644 --- a/src/FolderList.qml +++ b/src/FolderList.qml @@ -73,6 +73,7 @@ ListView { visible: list.count === 0 && !backend.folder.loading && backend.folder.error.length === 0 text: backend.folder.query ? qsTr("Nothing matches “%1”").arg(backend.folder.query) : plugins.views[backend.path] ? plugins.views[backend.path].empty : qsTr("This folder is empty") + textFormat: Text.PlainText color: theme.secondaryForeground // The way out of a search that found nothing. Text { diff --git a/src/FolderPane.qml b/src/FolderPane.qml index ba00d5ce..f34b3c34 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -360,6 +360,7 @@ FocusScope { id: bannerText Layout.fillWidth: true text: backend.message || backend.folder.error || (slowWork.shown ? slowWork.working : "") || backend.notice + textFormat: Text.PlainText color: theme.brightForeground elide: Text.ElideMiddle } diff --git a/src/FolioDialog.qml b/src/FolioDialog.qml index 1895041b..8da183b9 100644 --- a/src/FolioDialog.qml +++ b/src/FolioDialog.qml @@ -61,6 +61,7 @@ FocusScope { Layout.fillWidth: true text: dialog.title wrapMode: Text.Wrap + textFormat: Text.PlainText color: theme.brightForeground font.pixelSize: 16 font.weight: Font.DemiBold diff --git a/src/FormatDialog.qml b/src/FormatDialog.qml index 4bb60b5d..017aca94 100644 --- a/src/FormatDialog.qml +++ b/src/FormatDialog.qml @@ -54,12 +54,14 @@ FolioDialog { Layout.fillWidth: true visible: !!dialog.drive && dialog.drive.label.length > 0 text: dialog.drive ? dialog.drive.label : "" + textFormat: Text.PlainText color: theme.brightForeground elide: Text.ElideRight } Text { Layout.fillWidth: true text: dialog.drive ? dialog.drive.title : "" + textFormat: Text.PlainText color: dialog.drive && dialog.drive.label.length > 0 ? theme.secondaryForeground : theme.brightForeground elide: Text.ElideRight } diff --git a/src/MediaPreview.qml b/src/MediaPreview.qml index 91c0e258..401d305b 100644 --- a/src/MediaPreview.qml +++ b/src/MediaPreview.qml @@ -78,6 +78,7 @@ Item { Text { Layout.alignment: Qt.AlignHCenter text: player.metaData.stringValue(MediaMetaData.Title) || previewer.item.name || "" + textFormat: Text.PlainText color: theme.brightForeground font.pixelSize: 16 font.weight: Font.DemiBold diff --git a/src/PathBar.qml b/src/PathBar.qml index aadcb3f2..40b7d17c 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -122,6 +122,7 @@ Rectangle { anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter + textFormat: Text.PlainText text: backend.path.startsWith("plugin:") ? qsTr("Search %1").arg(here) : !settled ? qsTr("Search %1 and below").arg(here) : backend.folder.count === 0 ? qsTr("Searched %1 and below").arg(here) @@ -246,6 +247,7 @@ Rectangle { - (trail.first > 0 ? more.implicitWidth + crumbs.spacing : 0))) : implicitWidth elide: Text.ElideRight text: crumb.modelData.name + textFormat: Text.PlainText color: crumb.last ? theme.brightForeground : crumbMouse.containsMouse ? theme.foreground : theme.secondaryForeground font.pixelSize: 14 font.weight: crumb.last ? Font.DemiBold : Font.Normal diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 6a757e47..b2d42801 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -119,6 +119,7 @@ Rectangle { Layout.fillWidth: true horizontalAlignment: Text.AlignLeft text: pane.many ? qsTr("%Ln items selected", "", pane.many) : pane.item.fileName || "" + textFormat: Text.PlainText color: theme.brightForeground font.pixelSize: 15 font.weight: Font.DemiBold @@ -155,6 +156,7 @@ Rectangle { Layout.fillWidth: true horizontalAlignment: Text.AlignRight text: pane.trashed ? pane.homeless(pane.origin.replace(/\/[^\/]*$/, "") || "/") : "" + textFormat: Text.PlainText color: theme.foreground font.pixelSize: 12 elide: Text.ElideMiddle @@ -165,6 +167,7 @@ Rectangle { Layout.fillWidth: true horizontalAlignment: Text.AlignRight text: pane.item.folder || "" + textFormat: Text.PlainText color: theme.foreground font.pixelSize: 12 elide: Text.ElideMiddle diff --git a/src/PreviewWindow.qml b/src/PreviewWindow.qml index 109ff2db..d7db3a36 100644 --- a/src/PreviewWindow.qml +++ b/src/PreviewWindow.qml @@ -94,6 +94,7 @@ ApplicationWindow { Layout.fillWidth: true horizontalAlignment: Text.AlignLeft text: win.item.name || "" + textFormat: Text.PlainText color: theme.brightForeground font.pixelSize: 15 font.weight: Font.DemiBold @@ -383,6 +384,7 @@ ApplicationWindow { Text { Layout.alignment: Qt.AlignHCenter text: win.item.extension || "" + textFormat: Text.PlainText color: theme.accentText font.weight: Font.DemiBold } diff --git a/src/RenameDialog.qml b/src/RenameDialog.qml index 048627f0..85586a08 100644 --- a/src/RenameDialog.qml +++ b/src/RenameDialog.qml @@ -48,6 +48,7 @@ FolioDialog { horizontalAlignment: Text.AlignLeft visible: dialog.problem.length > 0 text: dialog.problem + textFormat: Text.PlainText color: theme.redText wrapMode: Text.Wrap } diff --git a/src/Sidebar.qml b/src/Sidebar.qml index e7e1cdfc..4bb906eb 100644 --- a/src/Sidebar.qml +++ b/src/Sidebar.qml @@ -225,6 +225,7 @@ Rectangle { Layout.fillWidth: true horizontalAlignment: Text.AlignLeft text: row.text + textFormat: Text.PlainText elide: Text.ElideRight color: row.active ? theme.brightForeground : theme.foreground } From b8efc812c0c0c19f1c057567111bc3ca4282e8cd Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 04:16:21 -0400 Subject: [PATCH 51/61] Draw each app's own icon in Open With The Open With menu listed bare app names. Each row now carries the icon from the app's desktop entry, 16 px from the icon theme, in a column kept empty for an app without one so the names stay aligned. App names draw as plain text, so markup in a desktop entry's Name shows as written. --- src/FileMenu.qml | 2 ++ src/FolioMenuItem.qml | 17 +++++++++++++- src/fileops.cpp | 21 ++++++++++++++++- src/fileops.h | 10 +++++++- src/main.cpp | 2 ++ tests/folio_tests.cpp | 54 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/FileMenu.qml b/src/FileMenu.qml index dcee506e..37f910f3 100644 --- a/src/FileMenu.qml +++ b/src/FileMenu.qml @@ -123,6 +123,8 @@ FolioMenu { FolioMenuItem { required property var modelData text: modelData.isDefault ? qsTr("%1 (default)").arg(modelData.name) : modelData.name + iconColumn: true + icon.source: modelData.icon ? "image://appicon/" + encodeURIComponent(modelData.icon) : "" onTriggered: fileOps.openWith(fileMenu.target.path, modelData.id) } } diff --git a/src/FolioMenuItem.qml b/src/FolioMenuItem.qml index 4176ea49..e9c818b2 100644 --- a/src/FolioMenuItem.qml +++ b/src/FolioMenuItem.qml @@ -19,6 +19,8 @@ MenuItem { // skips only entries that are disabled or out of the tab chain, so // Return would otherwise run one nobody can see. property bool shown: true + // Room for a 16 px icon.source before the label, kept when there's none so labels line up. + property bool iconColumn: false // Hover is off by default here; with it, the pointer highlights an // entry and opens a submenu on its own, as menus elsewhere do. @@ -40,13 +42,26 @@ MenuItem { } contentItem: Item { - implicitWidth: label.implicitWidth + hint.implicitWidth + 40 + implicitWidth: label.anchors.leftMargin + label.implicitWidth + hint.implicitWidth + 40 implicitHeight: label.implicitHeight + Image { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + visible: entry.iconColumn + width: 16 + height: 16 + sourceSize: Qt.size(16, 16) + fillMode: Image.PreserveAspectFit + // Synchronous: AppIconProvider calls QIcon::fromTheme, which belongs to the GUI thread. + source: entry.iconColumn ? entry.icon.source : "" + } Text { id: label anchors.left: parent.left + anchors.leftMargin: entry.iconColumn ? 26 : 0 anchors.verticalCenter: parent.verticalCenter text: entry.text + textFormat: Text.PlainText color: !entry.enabled ? theme.secondaryForeground : entry.highlighted ? theme.brightForeground : theme.foreground } Text { diff --git a/src/fileops.cpp b/src/fileops.cpp index 9128e890..557799be 100644 --- a/src/fileops.cpp +++ b/src/fileops.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -2035,8 +2036,11 @@ QVariantList FileOps::appsFor(const QString &path) const { // even a hidden one (Omarchy's editor handler), so it can be named. if (!g_app_info_should_show(info) && id != defaultId) continue; + GIcon *icon = g_app_info_get_icon(info); + char *iconName = icon ? g_icon_to_string(icon) : nullptr; const QVariantMap app{{"id", id}, {"name", QString::fromUtf8(g_app_info_get_name(info))}, - {"isDefault", id == defaultId}}; + {"icon", QString::fromUtf8(iconName)}, {"isDefault", id == defaultId}}; + g_free(iconName); if (id == defaultId) apps.prepend(app); else @@ -2047,6 +2051,21 @@ QVariantList FileOps::appsFor(const QString &path) const { return apps; } +QImage AppIconProvider::requestImage(const QString &id, QSize *size, const QSize &requestedSize) { + // Open With's icon column, for an Image that names no size. + constexpr int menuIconSize = 16; + const QSize want = requestedSize.isValid() ? requestedSize : QSize(menuIconSize, menuIconSize); + const QString name = QUrl::fromPercentEncoding(id.toUtf8()); + const QIcon icon = QFileInfo(name).isAbsolute() ? QIcon(name) : QIcon::fromTheme(name); + QImage image = icon.pixmap(want).toImage(); + if (image.isNull()) { + image = QImage(want, QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + } + *size = image.size(); + return image; +} + void FileOps::openWith(const QString &path, const QString &appId) { GDesktopAppInfo *app = g_desktop_app_info_new(appId.toUtf8().constData()); if (!app) diff --git a/src/fileops.h b/src/fileops.h index b0c53b2f..831954dc 100644 --- a/src/fileops.h +++ b/src/fileops.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -145,7 +146,7 @@ class FileOps : public QObject { Q_INVOKABLE void newFolder(const QString &folder); // The apps that can open a file, from the desktop's MIME database: - // {id, name, isDefault}, the default first. + // {id, name, icon (Icon=, a theme name or a path), isDefault}, the default first. Q_INVOKABLE QVariantList appsFor(const QString &path) const; Q_INVOKABLE void openWith(const QString &path, const QString &appId); // Makes the app what the file's type opens with from now on. @@ -345,3 +346,10 @@ class FileOps : public QObject { }; std::optional m_pending; }; + +// Serves image://appicon/ from the icon theme, blank when the theme has no such icon. +class AppIconProvider : public QQuickImageProvider { +public: + AppIconProvider() : QQuickImageProvider(QQuickImageProvider::Image) {} + QImage requestImage(const QString &id, QSize *size, const QSize &requestedSize) override; +}; diff --git a/src/main.cpp b/src/main.cpp index 9228dca5..bdbd4fa3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,6 +13,7 @@ #include #include "filemanager1.h" +#include "fileops.h" #include "foldermodel.h" #include "places.h" #include "plugins.h" @@ -142,6 +143,7 @@ int main(int argc, char *argv[]) { QObject::connect(&translator, &Translator::changed, &engine, &QQmlEngine::retranslate); engine.addImageProvider("thumb", new ThumbnailProvider(&thumbnails)); engine.addImageProvider("plugin-icon", new PluginIconProvider); + engine.addImageProvider("appicon", new AppIconProvider); // Creatable too: the column view lists each folder on the way here. qmlRegisterType("Folio", 1, 0, "FolderModel"); engine.rootContext()->setContextProperty("translator", &translator); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 89c4b1a4..819391f3 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -494,6 +494,10 @@ private slots: { // No test ever reaches the real udisks, which would change real disks. fakeDisks({}); + // GLib reads XDG_DATA_HOME once per process, so GIO's apps come from a made-up one for the whole run. + QVERIFY(gioHome.isValid()); + QVERIFY(QDir().mkpath(gioHome.filePath("data/applications"))); + qputenv("XDG_DATA_HOME", QFile::encodeName(gioHome.filePath("data"))); } void folderListsDirectoriesFirstWithoutHidden() @@ -6866,7 +6870,57 @@ private slots: QCOMPARE(model.matchStart(QString::fromUtf8("\u00c9T\u00c9 notes.txt")), 0); } + void appsForCarryTheirIcons() + { + // Open With draws each app's Icon= from the pass that lists the apps; one without keeps no icon. + const QString apps = gioHome.filePath("data/applications"); + write(apps + "/folio-test-viewer.desktop", "[Desktop Entry]\nType=Application\nName=Folio Test Viewer\n" + "Exec=true %f\nIcon=folio-test-viewer\nMimeType=text/markdown;\n"); + write(apps + "/folio-test-bare.desktop", "[Desktop Entry]\nType=Application\nName=Folio Test Bare\n" + "Exec=true %f\nMimeType=text/markdown;\n"); + // GIO finds an app by type through the cache update-desktop-database writes. + write(apps + "/mimeinfo.cache", "[MIME Cache]\ntext/markdown=folio-test-viewer.desktop;folio-test-bare.desktop;\n"); + QTemporaryDir dir; + write(dir.filePath("notes.md"), "# notes"); + FileOps ops; + auto iconOf = [&](const QString &id) { + for (const QVariant &app : ops.appsFor(dir.filePath("notes.md"))) + if (app.toMap().value("id") == id) + return app.toMap().value("icon").toString(); + return QString("missing"); + }; + QTRY_COMPARE(iconOf("folio-test-viewer.desktop"), QString("folio-test-viewer")); + QCOMPARE(iconOf("folio-test-bare.desktop"), QString()); + } + + void appIconsComeFromTheThemeOrAPath() + { + // A name the theme lacks leaves the column blank; a path, encoded as FileMenu encodes it, loads that file. + AppIconProvider provider; + QSize size; + const QImage blank = provider.requestImage("folio-test-no-such-icon", &size, QSize()); + QCOMPARE(blank.size(), QSize(16, 16)); + QCOMPARE(size, QSize(16, 16)); + for (int y = 0; y < blank.height(); ++y) + for (int x = 0; x < blank.width(); ++x) + QCOMPARE(qAlpha(blank.pixel(x, y)), 0); + + QTemporaryDir dir; + const QString path = dir.filePath("app icon.png"); + QImage red(48, 48, QImage::Format_ARGB32); + red.fill(Qt::red); + QVERIFY(red.save(path)); + const QString id = QString::fromUtf8(QUrl::toPercentEncoding(path)); + const QImage icon = provider.requestImage(id, &size, QSize(16, 16)); + QCOMPARE(icon.size(), QSize(16, 16)); + QCOMPARE(icon.pixelColor(8, 8), QColor(Qt::red)); + QCOMPARE(provider.requestImage(id, &size, QSize(24, 24)).size(), QSize(24, 24)); + QCOMPARE(size, QSize(24, 24)); + } + private: + QTemporaryDir gioHome; + // Packs `items`, from `folder`, into `archive`, its kind from its name. static void pack(const QString &archive, const QString &folder, const QStringList &items) { From e248b49cffd8d99f17a2d455996437b6bd13f16f Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 05:52:05 -0400 Subject: [PATCH 52/61] Draw the matched part of a result's name in bold The matched run was only a brighter colour, close to the rest of the name and invisible on the selected result, whose whole name is bright. It is now bold as well; the monospace face keeps its advance, so nothing reflows. --- src/Format.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Format.js b/src/Format.js index 6cfd2822..21bc73b9 100644 --- a/src/Format.js +++ b/src/Format.js @@ -48,12 +48,12 @@ function count(n) { return n === 0 ? qsTr("No items") : n > 0 ? qsTr("%Ln items", "", n) : "" } -// A search result's name as StyledText, its matched run in `color`; "" for no match or for white space StyledText folds. +// A search result's name as StyledText, its matched run bold in `color`; "" for no match or for white space StyledText folds. function lit(name, start, length, color) { if (start < 0 || /^\s|\s$|\s\s|[^\S ]|\u0085/.test(name)) return "" const escaped = text => text.replace(/&/g, "&").replace(//g, ">") - return escaped(name.slice(0, start)) + "" + escaped(name.slice(start, start + length)) + "" + escaped(name.slice(start + length)) + return escaped(name.slice(0, start)) + "" + escaped(name.slice(start, start + length)) + "" + escaped(name.slice(start + length)) } // What an item is and how big, under its name in the details and the From b505b7a6d2e821e3284e40733ad6893dc29c1e59 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 10:34:06 -0400 Subject: [PATCH 53/61] Leave the key gap off a menu row without a key A row with no key or arrow still counted the key column and its 40 px gap in its width, so a menu fitted to its widest row stood wide of Open With's longest name. The gap now counts only when a row has a key or an arrow. --- src/FolioMenuItem.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/FolioMenuItem.qml b/src/FolioMenuItem.qml index e9c818b2..83652d22 100644 --- a/src/FolioMenuItem.qml +++ b/src/FolioMenuItem.qml @@ -42,7 +42,8 @@ MenuItem { } contentItem: Item { - implicitWidth: label.anchors.leftMargin + label.implicitWidth + hint.implicitWidth + 40 + // The gap before a key or arrow only when there is one, so a plain row's width is its label and padding. + implicitWidth: label.anchors.leftMargin + label.implicitWidth + (entry.keys || entry.subMenu ? hint.implicitWidth + 40 : 0) implicitHeight: label.implicitHeight Image { anchors.left: parent.left From 07d5330d320634aafbc06ffdff6d2d472b47154e Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 05:54:27 -0400 Subject: [PATCH 54/61] Render Markdown in the details pane, without pictures The details pane showed a Markdown file's opening lines raw. It now renders them, converted off the UI thread to HTML with every picture replaced by its alt text, so selecting a file fetches nothing; raw HTML stays text. Rich text cannot elide, so the card ends on the last line that fits whole, and a table stops at 300 cells, which the card lays out in under one 10 ms frame. --- src/PreviewPane.qml | 26 +++++++++++++++++++++++++ src/backend.cpp | 10 ++++++++-- src/backend.h | 7 ++++--- src/previewer.cpp | 18 +++++++++++++++++ src/previewer.h | 2 ++ tests/folio_tests.cpp | 45 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index b2d42801..100e8e01 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -98,6 +98,32 @@ Rectangle { textFormat: Text.PlainText elide: Text.ElideRight } + // A Markdown file, rendered. Rich text can't elide, so the frame ends on the last line that fits whole. + Item { + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + readonly property real room: parent.height - 28 + height: { + markdown.length, markdown.width, markdown.contentHeight + const line = markdown.positionToRectangle(markdown.positionAt(0, room)) + return line.y + line.height > room ? Math.min(line.y, room) : room + } + clip: true + TextEdit { + id: markdown + width: parent.width + text: pane.info.markdown || "" + textFormat: TextEdit.RichText + color: theme.foreground + font.pixelSize: 10 + wrapMode: TextEdit.WrapAtWordBoundaryOrAnywhere + readOnly: true + // Only shown: nothing to select, and its links aren't followed from here. + enabled: false + } + } } Glyph { diff --git a/src/backend.cpp b/src/backend.cpp index 6224ee0c..d13fb2a2 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -393,7 +393,7 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { // Stepping through files faster than they're read queues readings; the // ones overtaken before they start are skipped rather than left to tie // up the threads the next folder is listed on. - watcher->setFuture(QtConcurrent::run([path, kind, inTrash, latest = m_describing, generation] { + watcher->setFuture(QtConcurrent::run([path, kind, mime, inTrash, latest = m_describing, generation] { QVariantMap read; if (*latest != generation) return read; @@ -416,7 +416,13 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { } else if (kind == "text") { // Enough of the top of the file to recognize it, never the whole // thing: a log can be gigabytes. - read.insert("text", Previewer::readText(path, 6000).section('\n', 0, 40)); + const QString text = Previewer::readText(path, 6000).section('\n', 0, 40); + // Table cells cost the UI thread most to lay out; this many keep the card under a 10 ms frame. + constexpr int markdownCells = 300; + if (mime == "text/markdown") + read.insert("markdown", Previewer::markdownHtml(Previewer::markdownOpening(text, markdownCells))); + else + read.insert("text", text); } return read; })); diff --git a/src/backend.h b/src/backend.h index 7d0f582d..54f24624 100644 --- a/src/backend.h +++ b/src/backend.h @@ -29,9 +29,10 @@ class Backend : public QObject { Q_PROPERTY(bool previewing READ previewing NOTIFY previewingChanged) // What the details pane shows beyond the listing, for the item last // given to describe(), once they've been read in the background: a - // picture's dimensions, a text's opening lines, or a trashed item's - // origin and deletion time; and under `plugins`, plugins' rows about a - // file or folder, [[label, value]...]. + // picture's dimensions, a text's opening lines (a Markdown file's as + // HTML, markdown), or a trashed item's origin and deletion time; and + // under `plugins`, plugins' rows about a file or folder, + // [[label, value]...]. Q_PROPERTY(QVariantMap details READ details NOTIFY detailsChanged) public: diff --git a/src/previewer.cpp b/src/previewer.cpp index 7ac8bf0e..c9049d76 100644 --- a/src/previewer.cpp +++ b/src/previewer.cpp @@ -6,6 +6,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -108,6 +111,21 @@ QString Previewer::markdownOpening(const QString &text, int cells) { return text; } +QString Previewer::markdownHtml(const QString &markdown) { + QTextDocument document; + document.setMarkdown(markdown, QTextDocument::MarkdownFeatures(QTextDocument::MarkdownDialectGitHub | QTextDocument::MarkdownNoHTML)); + // Drawing a picture would fetch it, a remote one too, just because the file was selected. + QTextCursor cursor(&document); + while (!(cursor = document.find(QString(QChar::ObjectReplacementCharacter), cursor)).isNull()) { + const QTextCharFormat format = cursor.charFormat(); + if (format.isImageFormat()) + cursor.insertText(format.property(QTextFormat::ImageAltText).toString(), QTextCharFormat()); + } + // toHtml writes . + // Without the document's own font the text takes the one it's shown in. + return document.toHtml().replace(QRegularExpression(QStringLiteral("")), QStringLiteral("")); +} + void Previewer::start(const QString &path, bool showHidden, const QString &sort) { m_showHidden = showHidden; m_sort = sort; diff --git a/src/previewer.h b/src/previewer.h index 9aee60e9..fc9c8c43 100644 --- a/src/previewer.h +++ b/src/previewer.h @@ -65,6 +65,8 @@ class Previewer : public QObject { // `cells` cells: laid out, each costs the UI thread about as much as a // whole line of prose, and a long table froze the preview for seconds. static QString markdownOpening(const QString &text, int cells = 600); + // Markdown as HTML, each picture replaced by its alt text so showing it loads nothing; raw HTML stays text. + static QString markdownHtml(const QString &markdown); signals: void changed(); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 819391f3..4be20cdd 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -3406,6 +3406,51 @@ private slots: QVERIFY(!backend.details().contains("width")); } + void backendRendersMarkdownWithoutItsPictures() + { + // Drawing a Markdown file's picture would fetch it, a remote one too, just for selecting the file. + QTemporaryDir dir; + const QString notes = dir.filePath("notes.md"), plain = dir.filePath("notes.txt"); + write(notes, "# Field notes\n\nThe *falling* tide, the **long** lens.\n\n- dusk\n- dawn\n\n" + "![shoreline](shore.png) ![tidepool](http://example.com/tide.png) ![clifftop](https://example.com/cliff.png)" + " ![](https://example.com/bare.png) ![emblem][logo]\n\n\n\n" + "[logo]: https://example.com/logo.png\n"); + write(plain, "# Field notes\n![shore](shore.png)\n"); + Backend backend(dir.path()); + backend.describe({{"path", notes}, {"kind", "text"}, {"mime", "text/markdown"}}); + QTRY_VERIFY(backend.details().contains("markdown")); + QVERIFY(!backend.details().contains("text")); + const QString html = backend.details().value("markdown").toString(); + QVERIFY2(!html.contains(" 0); + QVERIFY2(cut.count(" Date: Wed, 11 Nov 2026 05:50:30 -0400 Subject: [PATCH 55/61] Draw the audio stand-in as sound, with a chip The audio stand-in drew 18 bars of nearly one height, a comb that read as a barcode, and its extension as bare grey text. The name is hashed once and the bars spread from 20 to 48 px, and the extension wears the chip the page stand-in wears, in the same corner. --- src/FileTile.qml | 57 ++++++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/FileTile.qml b/src/FileTile.qml index 16ecef1f..53dcb517 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -335,26 +335,28 @@ Item { // An archive is the lidded box its row draws, not a page of text. Glyph { anchors.centerIn: parent; visible: tile.kind === "archive"; name: "archive"; size: 32; color: tile.kindColor } } - Rectangle { - visible: tile.extension.length > 0 - anchors.left: page.left - anchors.bottom: page.bottom - anchors.bottomMargin: 6 - width: badge.implicitWidth + 8 - height: badge.implicitHeight + 2 - color: tile.kindColor - Text { - id: badge - anchors.centerIn: parent - text: tile.extension.slice(0, 4) - textFormat: Text.PlainText - font.pixelSize: 9 - font.bold: true - color: { - theme.background // Re-read when the theme changes, not only the fill. - return theme.inkOn(tile.kindColor) - } - } + ExtensionBadge { anchors.left: page.left } + } + } + + // The extension on a stand-in, as a chip in its kind's color at the foot's left. + component ExtensionBadge: Rectangle { + visible: tile.extension.length > 0 + anchors.bottom: parent.bottom + anchors.bottomMargin: 6 + width: badge.implicitWidth + 8 + height: badge.implicitHeight + 2 + color: tile.kindColor + Text { + id: badge + anchors.centerIn: parent + text: tile.extension.slice(0, 4) + textFormat: Text.PlainText + font.pixelSize: 9 + font.bold: true + color: { + theme.background // Re-read when the theme changes, not only the fill. + return theme.inkOn(tile.kindColor) } } } @@ -365,6 +367,12 @@ Item { // Decorative, not the real waveform: bars seeded from the name so each // file keeps its own shape. Album art replaces it when the file has some. Rectangle { + id: wave + readonly property int seed: { + let h = 0 + for (let i = 0; i < tile.name.length; ++i) h = (h * 31 + tile.name.charCodeAt(i)) % 997 + return h + } color: theme.lighterBackground radius: tile.iconRadius Row { @@ -374,18 +382,15 @@ Item { model: 18 Rectangle { required property int index + readonly property int step: (wave.seed + index * 389) % 997 anchors.verticalCenter: parent.verticalCenter width: 3 - height: { - let h = 0 - for (let i = 0; i < tile.name.length; ++i) h = (h * 31 + tile.name.charCodeAt(i) + index * 7) % 997 - return 10 + (h % 40) - } + height: 20 + (step * step % 997) % 29 color: tile.kindColor } } } - Text { anchors.right: parent.right; anchors.bottom: parent.bottom; anchors.margins: 5; text: tile.extension; textFormat: Text.PlainText; font.pixelSize: 9; color: theme.secondaryForeground } + ExtensionBadge { anchors.left: parent.left } } } From 7e04504bb0f114fdee03ce09e483d1c4d995395e Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 05:51:02 -0400 Subject: [PATCH 56/61] Keep typed search text inside a narrow field In a split window the search hint could be wider than the field, so the typed text had no room and drew left of the field over the other pane. The hint now elides its place name, then hides, keeping room for typing, and the field clips. At full width nothing changes. --- src/PathBar.qml | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/PathBar.qml b/src/PathBar.qml index 40b7d17c..ecf3851f 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -102,9 +102,11 @@ Rectangle { Layout.fillWidth: true visible: bar.searching && !bar.editing font.pixelSize: 14 - // Room at the end for where it searches; padding isn't mirrored. - leftPadding: LayoutMirroring.enabled ? scope.implicitWidth + 24 : 12 - rightPadding: LayoutMirroring.enabled ? 12 : scope.implicitWidth + 24 + clip: true + // Room at the end for where it searches, while it fits; padding isn't mirrored. + readonly property real hintRoom: scope.fits ? scope.implicitWidth + 24 : 12 + leftPadding: LayoutMirroring.enabled ? hintRoom : 12 + rightPadding: LayoutMirroring.enabled ? 12 : hintRoom // Where it searches, at the end of the field. Unlike a // placeholder, it stays while typing. Plugins' views have // nothing below them. @@ -119,17 +121,28 @@ Rectangle { // Once the results for what is typed are in, how many there are. readonly property bool settled: backend.folder.query.length > 0 && backend.folder.query === searchField.text.trim() && !backend.folder.loading && backend.folder.error.length === 0 + readonly property string hint: backend.path.startsWith("plugin:") ? qsTr("Search %1") + : !settled ? qsTr("Search %1 and below") + : backend.folder.count === 0 ? qsTr("Searched %1 and below") + : backend.folder.count >= backend.folder.searchLimit ? qsTr("%Ln or more found in %1 and below", "", backend.folder.count) + : qsTr("%Ln found in %1 and below", "", backend.folder.count) + // In a narrow field it gives way to what is typed: the place elides, then the hint hides. + readonly property bool fits: placeFit.elidedText === here || placeFit.elidedText.length > 3 + visible: fits anchors.right: parent.right anchors.rightMargin: 12 anchors.verticalCenter: parent.verticalCenter textFormat: Text.PlainText - text: backend.path.startsWith("plugin:") ? qsTr("Search %1").arg(here) - : !settled ? qsTr("Search %1 and below").arg(here) - : backend.folder.count === 0 ? qsTr("Searched %1 and below").arg(here) - : backend.folder.count >= backend.folder.searchLimit ? qsTr("%Ln or more found in %1 and below", "", backend.folder.count).arg(here) - : qsTr("%Ln found in %1 and below", "", backend.folder.count).arg(here) + text: hint.arg(placeFit.elidedText) color: theme.secondaryForeground font.pixelSize: 12 + // The field's width kept for typing, a dozen characters. + readonly property int typingRoom: 96 + TextMetrics { id: hintRest; font: scope.font; text: scope.hint.arg("") } + // The padding on the side the typing starts from, which mirroring swaps. + readonly property real startPadding: LayoutMirroring.enabled ? searchField.rightPadding : searchField.leftPadding + // The hint keeps its margin on both sides, as rightPadding does. + TextMetrics { id: placeFit; font: scope.font; text: scope.here; elide: Text.ElideRight; elideWidth: searchField.width - scope.startPadding - 2 * scope.anchors.rightMargin - scope.typingRoom - hintRest.advanceWidth } } onTextEdited: searchDelay.restart() // Enter or ↓ goes to the results, leaving the search up. From 41e9896299b86a7ff8d6a51dbddcce291cc934fd Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 10:35:57 -0400 Subject: [PATCH 57/61] Keep the parent in view before folding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A split pane's path folded straight to "… / Exports" when its parent did not fit whole, leaving no hint of where the folder is. The parent now elides to stay beside the current folder, ending where its name ends, and folds only when fewer than about six characters of it would show. --- src/PathBar.qml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/PathBar.qml b/src/PathBar.qml index ecf3851f..7832ea2b 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -168,6 +168,12 @@ Rectangle { // The end stays in view; crumbs that don't fit whole fold into "…" at the reading start (the right, when mirrored). property int first: 0 property real offset: 0 + // The parent's width while it elides to stay beside the current folder; 0 when it shows whole. + property real squeeze: 0 + // The narrowest the parent elides to before it folds, about six characters. + readonly property int squeezeFloor: 56 + // The squeezed parent's crumb is as wide as its elided name, so the next separator keeps the usual gap. + FontMetrics { id: crumbFont; font.pixelSize: 14 } // Measured from each crumb's full width, so the current folder's own elide never changes what folds. function fit() { let rest = 0 @@ -177,6 +183,11 @@ Rectangle { if (rest > width) while (i < crumbRepeater.count - 1 && rest > width - more.implicitWidth - crumbs.spacing) rest -= crumbRepeater.itemAt(i++).fullWidth + crumbs.spacing + // Before the parent folds too, it elides, down to squeezeFloor. + const room = width - rest - crumbs.spacing - (i > 1 ? more.implicitWidth + crumbs.spacing : 0) + squeeze = i > 0 && i === crumbRepeater.count - 1 && room >= Math.min(crumbRepeater.itemAt(i - 1).fullWidth, squeezeFloor) ? room : 0 + if (squeeze > 0) + --i first = i // How far the first shown crumb is from the row's start, which a mirrored Row puts at its right. const shown = crumbRepeater.itemAt(i) @@ -257,7 +268,11 @@ Rectangle { id: folderName // The current folder always shows; alone too wide, it elides. width: crumb.last ? Math.max(0, Math.min(implicitWidth, trail.width - (separator.visible ? separator.implicitWidth + crumb.spacing : 0) - - (trail.first > 0 ? more.implicitWidth + crumbs.spacing : 0))) : implicitWidth + - (trail.first > 0 ? more.implicitWidth + crumbs.spacing : 0))) + : crumb.index === trail.first && trail.squeeze > 0 + ? Math.ceil(crumbFont.advanceWidth(crumbFont.elidedText(crumb.modelData.name, Qt.ElideRight, + trail.squeeze - (separator.visible ? separator.implicitWidth + crumb.spacing : 0)))) + : implicitWidth elide: Text.ElideRight text: crumb.modelData.name textFormat: Text.PlainText From 53c52f294f9d0ed0d36da2fdde416d5275b66ee5 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 05:55:39 -0400 Subject: [PATCH 58/61] Draw all text gray, without LCD color fringes Qt Quick gives a text's new glyph nodes subpixel antialiasing and the nodes it reuses on a redraw gray, so neighbours in one color drew two ways. Folio now asks for gray distance field text, unless the environment already sets QSG_DISTANCEFIELD_ANTIALIASING. --- src/main.cpp | 6 ++++-- src/theme.cpp | 10 ++++++++++ src/theme.h | 4 ++++ tests/folio_tests.cpp | 25 +++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index bdbd4fa3..014859dc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -108,7 +108,7 @@ static int preview(QGuiApplication &app) { engine.rootContext()->setContextProperty("translator", &translator); engine.rootContext()->setContextProperty("theme", &theme); engine.rootContext()->setContextProperty("previewer", &previewer); - engine.load(QUrl("qrc:/PreviewWindow.qml")); + Theme::grayText([&engine] { engine.load(QUrl("qrc:/PreviewWindow.qml")); }); if (engine.rootObjects().isEmpty()) return 1; return app.exec(); @@ -152,7 +152,9 @@ int main(int argc, char *argv[]) { engine.rootContext()->setContextProperty("plugins", &plugins); Windows windows(&engine, &plugins); - if (!windows.open(start, select)) + bool opened = false; + Theme::grayText([&] { opened = windows.open(start, select) != nullptr; }); + if (!opened) return 1; FileManager1::registerOn(&windows); diff --git a/src/theme.cpp b/src/theme.cpp index d6058ccb..d4e38e98 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -298,3 +298,13 @@ QColor Theme::inkOn(const QColor &fill) const { return bg; return ensureRatio(ratio(bg, fill) >= ratio(fg, fill) ? bg : fg, fill, textFloor); } + +void Theme::grayText(const std::function &firstWindow) { + // Qt reads it once per process, as the first window is made, so apps opened from Folio don't inherit it. + const bool ask = !qEnvironmentVariableIsSet("QSG_DISTANCEFIELD_ANTIALIASING"); + if (ask) + qputenv("QSG_DISTANCEFIELD_ANTIALIASING", "gray"); + firstWindow(); + if (ask) + qunsetenv("QSG_DISTANCEFIELD_ANTIALIASING"); +} diff --git a/src/theme.h b/src/theme.h index 0285fa78..0261f99c 100644 --- a/src/theme.h +++ b/src/theme.h @@ -6,6 +6,7 @@ #include #include #include +#include // The Omarchy palette, followed live. A file manager draws far more surface // than a one-tool app, so it reads the whole colors.toml rather than only the @@ -63,6 +64,9 @@ class Theme : public QObject { QColor yellowText() const; Q_INVOKABLE QColor inkOn(const QColor &fill) const; + // Makes the first window with gray text asked for, unless the environment chose: Qt Quick draws new glyphs with LCD fringes, reused ones gray. + static void grayText(const std::function &firstWindow); + signals: void changed(); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 4be20cdd..d66c6fbd 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6885,6 +6885,31 @@ private slots: QVERIFY(raisedInks > 0); } + void themeAsksForGrayTextOnlyWhileTheFirstWindowIsMade() + { + const char *name = "QSG_DISTANCEFIELD_ANTIALIASING"; + const bool wasSet = qEnvironmentVariableIsSet(name); + const QByteArray was = qgetenv(name); + qunsetenv(name); + QByteArray seen; + Theme::grayText([&] { + seen = qgetenv(name); + QQuickWindow window; + }); + QCOMPARE(seen, QByteArray("gray")); + QVERIFY(!qEnvironmentVariableIsSet(name)); + + // A value set before Folio starts stays, during the first window and after. + qputenv(name, "subpixel"); + Theme::grayText([&] { seen = qgetenv(name); }); + QCOMPARE(seen, QByteArray("subpixel")); + QCOMPARE(qgetenv(name), QByteArray("subpixel")); + if (wasSet) + qputenv(name, was); + else + qunsetenv(name); + } + void folderMatchStartIsWhereTheSearchMatched() { QTemporaryDir dir; From 873024c4490b73a2ec63eaad6f30a3a1f40a44a3 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 08:19:07 -0400 Subject: [PATCH 59/61] Keep the folder card's fill visible on every theme On many themes the lighter background sits within 1.05 to 1.3:1 of the background, so grid folder cards nearly vanished. The card fill is now the lighter background raised to 1.4:1; the hover, current and drop tints still draw on top of it. --- src/FileTile.qml | 8 ++++---- src/theme.cpp | 7 +++++++ src/theme.h | 2 ++ tests/folio_tests.cpp | 19 ++++++++++++++++++- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/FileTile.qml b/src/FileTile.qml index 53dcb517..fe0d1aed 100644 --- a/src/FileTile.qml +++ b/src/FileTile.qml @@ -189,10 +189,10 @@ Item { height: 92 preferredRendererType: Shape.CurveRenderer ShapePath { - 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 + fillColor: tile.current && !tile.inactive || folderDrop.containsDrag ? Qt.tint(theme.folderFill, Qt.alpha(theme.accent, 0.3)) + : tile.current ? Qt.tint(theme.folderFill, Qt.alpha(theme.foreground, 0.08)) + : hover.hovered ? Qt.tint(theme.folderFill, Qt.alpha(theme.foreground, 0.06)) + : theme.folderFill strokeColor: (tile.current || tile.cursor) && !tile.inactive || folderDrop.containsDrag ? theme.accentMark : tile.current || tile.cursor ? theme.quietMark : "transparent" strokeWidth: folderDrop.containsDrag ? 2.5 : tile.cursor ? 1 : 1.5 diff --git a/src/theme.cpp b/src/theme.cpp index d4e38e98..4285e48f 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -59,6 +59,8 @@ constexpr int bisectionSteps = 18; constexpr int channelRange = 255; // WCAG's minimum contrast for marks and frames, which aren't text. constexpr double markFloor = 3; +// The least contrast a folder card's fill keeps on the pane: a surface, so well under markFloor. +constexpr double folderFloor = 1.4; // The pin box's accent tint in Sidebar.qml while a drop hovers it, the strongest ground under its words. constexpr double strongestPinTint = 0.22; // At or under this saturation a theme's red is a grey. @@ -265,6 +267,11 @@ QColor Theme::quietMark() const { return ensureRatio(ensureRatio(mix(bg, fg, share), bg, markFloor), card, markFloor); } +// A folder card's fill: the lighter background, raised to folderFloor so the card stands off the pane. +QColor Theme::folderFill() const { + return ensureRatio(lighterBackground(), background(), folderFloor); +} + // A disabled glyph: 3:1 on the bar, while the secondary ink of an off toggle keeps 4.5:1. QColor Theme::disabledForeground() const { // The foreground's share of a disabled glyph before the floor. diff --git a/src/theme.h b/src/theme.h index 0261f99c..f79c3f3a 100644 --- a/src/theme.h +++ b/src/theme.h @@ -31,6 +31,7 @@ class Theme : public QObject { Q_PROPERTY(int radius READ radius NOTIFY changed) Q_PROPERTY(QColor accentMark READ accentMark NOTIFY changed) Q_PROPERTY(QColor quietMark READ quietMark NOTIFY changed) + Q_PROPERTY(QColor folderFill READ folderFill NOTIFY changed) Q_PROPERTY(QColor disabledForeground READ disabledForeground NOTIFY changed) Q_PROPERTY(QColor redText READ redText NOTIFY changed) Q_PROPERTY(QColor accentText READ accentText NOTIFY changed) @@ -58,6 +59,7 @@ class Theme : public QObject { int radius() const { return m_radius; } QColor accentMark() const; QColor quietMark() const; + QColor folderFill() const; QColor disabledForeground() const; QColor redText() const; QColor accentText() const; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index d66c6fbd..99a94b0c 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1443,7 +1443,8 @@ private slots: // A real model, whose matchStart a search result's name calls. FolderModel folder; for (const char *role : {"accent", "accentMark", "foreground", "brightForeground", "secondaryForeground", - "lighterBackground", "darkBackground", "red", "blue", "magenta", "orange", "yellow", "cyan"}) + "lighterBackground", "darkBackground", "folderFill", "red", "blue", "magenta", + "orange", "yellow", "cyan"}) theme->insert(role, QColor(Qt::gray)); theme->insert("radius", 0); translator->insert("language", "en"); @@ -6744,6 +6745,22 @@ private slots: QCOMPARE(passing.quietMark(), ring); } + void themeFloorsTheFolderCard() + { + // Rose Pine Dawn's card is 1.1:1 on its background; White's already stands off at 1.8:1. + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + write(dir.filePath("theme/colors.toml"), roseDawn); + Theme low(dir.path()); + QVERIFY(contrast(low.folderFill(), low.background()) >= 1.4); + QVERIFY(snug(low.folderFill(), {low.background()}, 1.4)); + // Tiles and panels keep the theme's own lighter background. + QCOMPARE(low.lighterBackground(), QColor("#f2e9e1")); + write(dir.filePath("theme/colors.toml"), "mode = \"light\"\nbackground = \"#ffffff\"\nlighter_background = \"#c0c0c0\"\nforeground = \"#000000\"\n"); + Theme passing(dir.path()); + QCOMPARE(passing.folderFill(), QColor("#c0c0c0")); + } + void themeFloorsTheDisabledGlyph() { QTemporaryDir dir; From 36d06140510d2c83a5bfd1c667a3211e4abe382d Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 11:28:20 -0400 Subject: [PATCH 60/61] Draw app names as plain text The Open button's "Open with %1" and the button that picks what a file opens with took Text's AutoText, so markup in a desktop entry's Name drew as rich text. Both now draw plain text, as file names and the Open With menu already do. --- src/PlainButton.qml | 1 + src/PreviewPane.qml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PlainButton.qml b/src/PlainButton.qml index 1d999b41..7e715c6c 100644 --- a/src/PlainButton.qml +++ b/src/PlainButton.qml @@ -46,6 +46,7 @@ AbstractButton { contentItem: Text { id: label text: button.text + textFormat: Text.PlainText color: button.destructive ? theme.background : button.primary ? theme.accentForeground : theme.foreground font.weight: button.filled ? Font.DemiBold : Font.Normal horizontalAlignment: Text.AlignHCenter diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 100e8e01..f8a9e1cf 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -317,7 +317,7 @@ Rectangle { } contentItem: RowLayout { // A long app name ends in … rather than running past the button. - Text { Layout.leftMargin: 14; Layout.fillWidth: true; text: openButton.text; elide: Text.ElideRight; color: theme.accentForeground; font.weight: Font.DemiBold } + Text { Layout.leftMargin: 14; Layout.fillWidth: true; text: openButton.text; textFormat: Text.PlainText; elide: Text.ElideRight; color: theme.accentForeground; font.weight: Font.DemiBold } Text { Layout.rightMargin: 14; text: "⏎"; color: theme.accentForeground } } } From d220bcb43075fdaf54ce8fa417aa4893ce3173a8 Mon Sep 17 00:00:00 2001 From: GM Date: Fri, 13 Nov 2026 01:01:47 -0400 Subject: [PATCH 61/61] Give a rendered Markdown page room between its blocks Qt reads Markdown with a heading on its text and a table on the lines around it. The page now has room after each block, padded table cells between quiet rules, a ground under code, and links in the text's own color; the preview window shows the same page as the details pane. --- src/PreviewWindow.qml | 4 +- src/backend.cpp | 7 +- src/previewer.cpp | 103 ++++++++++++++++++- src/previewer.h | 10 +- tests/folio_tests.cpp | 226 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 338 insertions(+), 12 deletions(-) diff --git a/src/PreviewWindow.qml b/src/PreviewWindow.qml index d7db3a36..bf3a3eaa 100644 --- a/src/PreviewWindow.qml +++ b/src/PreviewWindow.qml @@ -302,8 +302,8 @@ ApplicationWindow { selectByMouse: true activeFocusOnPress: false wrapMode: TextEdit.Wrap - textFormat: win.viewer === "markdown" ? TextEdit.MarkdownText : TextEdit.PlainText - text: previewer.text + textFormat: win.viewer === "markdown" ? TextEdit.RichText : TextEdit.PlainText + text: win.viewer === "markdown" ? previewer.html : previewer.text color: theme.foreground selectionColor: theme.accent selectedTextColor: theme.accentForeground diff --git a/src/backend.cpp b/src/backend.cpp index d13fb2a2..3909d380 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -419,10 +419,11 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { const QString text = Previewer::readText(path, 6000).section('\n', 0, 40); // Table cells cost the UI thread most to lay out; this many keep the card under a 10 ms frame. constexpr int markdownCells = 300; - if (mime == "text/markdown") - read.insert("markdown", Previewer::markdownHtml(Previewer::markdownOpening(text, markdownCells))); - else + // A Markdown file with no text to show gets no page, as in the preview window. + if (mime != "text/markdown") read.insert("text", text); + else if (const QString opening = Previewer::markdownOpening(text, markdownCells); !opening.isEmpty()) + read.insert("markdown", Previewer::markdownHtml(opening, Previewer::cardPixels)); } return read; })); diff --git a/src/previewer.cpp b/src/previewer.cpp index c9049d76..89bf924b 100644 --- a/src/previewer.cpp +++ b/src/previewer.cpp @@ -3,15 +3,19 @@ #include "fileops.h" #include #include +#include #include #include #include #include #include #include +#include +#include #include #include #include +#include #include Previewer::Previewer(QObject *parent) : QObject(parent) {} @@ -111,7 +115,7 @@ QString Previewer::markdownOpening(const QString &text, int cells) { return text; } -QString Previewer::markdownHtml(const QString &markdown) { +QString Previewer::markdownHtml(const QString &markdown, int pixels) { QTextDocument document; document.setMarkdown(markdown, QTextDocument::MarkdownFeatures(QTextDocument::MarkdownDialectGitHub | QTextDocument::MarkdownNoHTML)); // Drawing a picture would fetch it, a remote one too, just because the file was selected. @@ -121,6 +125,89 @@ QString Previewer::markdownHtml(const QString &markdown) { if (format.isImageFormat()) cursor.insertText(format.property(QTextFormat::ImageAltText).toString(), QTextCharFormat()); } + // The room Qt leaves between Markdown blocks follows neither their kind nor the text's size, so each kind is given its own here. + const auto part = [pixels](qreal of) { return qRound(of * pixels); }; + // Greys let through enough to stay quiet on a dark ground and on a light one: a table's rules, and the ground under code. + const QColor rule(128, 128, 128, 96), ground(128, 128, 128, 36); + constexpr int linePercent = 120; + // The room above and below each kind of block, in parts of the text's size. + constexpr qreal headingAbove = 1.1, smallHeadingAbove = 0.8, headingBelow = 0.55, listAbove = 0.45, itemBelow = 0.15, textBelow = 0.75, cellRoom = 0.5, tableAbove = 0.3, tableBelow = 0.9; + // Headings from this level on are the smaller ones. + constexpr int smallHeading = 3; + // The font Qt sets code in when it reads Markdown, which is how a run of code is told from the text around it. + const QStringList codeFont = QFontDatabase::systemFont(QFontDatabase::FixedFont).families(); + const auto isCode = [](const QTextBlockFormat &of) { return of.hasProperty(QTextFormat::BlockCodeFence) || of.hasProperty(QTextFormat::BlockCodeLanguage); }; + const QTextList *list = nullptr, *edge = nullptr; + for (QTextBlock block = document.begin(); block.isValid(); block = block.next()) { + QTextCursor at(block); + QTextBlockFormat format = block.blockFormat(); + const QTextList *before = std::exchange(list, block.textList()); + const bool item = list != nullptr, wasListed = before != nullptr; + // A list at the page's edge that follows another there is a list of its own, whatever the one before ended on; one further in belongs to the item above it. + const bool atEdge = item && list->format().indent() <= 1; + const bool nextList = atEdge && wasListed && edge && list != edge; + if (atEdge) + edge = list; + const bool code = isCode(format); + // Code in a line gets the ground a code block has, and a link the text's own color: it can't be followed from here, and Qt's blue is lost on a dark ground. + struct Run { int position, length; QTextCharFormat shown; }; + QVector runs; + for (QTextBlock::iterator run = block.begin(); !code && !run.atEnd(); ++run) { + QTextCharFormat shown = run.fragment().charFormat(); + if (shown.isAnchor()) { + shown.clearProperty(QTextFormat::AnchorHref); + shown.clearForeground(); + shown.setFontUnderline(true); + } else if (shown.fontFamilies().toStringList() == codeFont) { + shown.setBackground(ground); + } else { + continue; + } + runs.append({run.fragment().position(), run.fragment().length(), shown}); + } + // Set after the walk: a run given its neighbor's format joins it, and the walk would lose its place. + for (const Run &run : runs) { + QTextCursor over(&document); + over.setPosition(run.position); + over.setPosition(run.position + run.length, QTextCursor::KeepAnchor); + over.setCharFormat(run.shown); + } + if (at.currentTable()) { + // A cell's room is its padding. + continue; + } else if (format.headingLevel() > 0) { + format.setTopMargin(block == document.begin() ? 0 : part(format.headingLevel() < smallHeading ? headingAbove : smallHeadingAbove)); + format.setBottomMargin(part(headingBelow)); + } else if (item) { + format.setTopMargin(nextList ? part(listAbove) : 0); + format.setBottomMargin(part(itemBelow)); + format.setLineHeight(linePercent, QTextBlockFormat::ProportionalHeight); + } else { + // The lines of one code block touch. Qt files two blocks in a row as one run of lines, so only a change of language tells them apart. + const QTextBlockFormat next = block.next().blockFormat(); + const bool more = code && block.next().isValid() && isCode(next) && next.property(QTextFormat::BlockCodeLanguage) == format.property(QTextFormat::BlockCodeLanguage); + format.setTopMargin(wasListed ? part(listAbove) : 0); + format.setBottomMargin(more ? 0 : part(textBelow)); + if (code) + format.setBackground(ground); + else + format.setLineHeight(linePercent, QTextBlockFormat::ProportionalHeight); + } + at.setBlockFormat(format); + } + for (QTextFrame *frame : document.rootFrame()->childFrames()) { + auto *table = qobject_cast(frame); + if (!table) + continue; + QTextTableFormat format = table->format(); + format.setCellPadding(part(cellRoom)); + format.setCellSpacing(0); + format.setBorderStyle(QTextFrameFormat::BorderStyle_Solid); + format.setBorderBrush(rule); + format.setTopMargin(part(tableAbove)); + format.setBottomMargin(part(tableBelow)); + table->setFormat(format); + } // toHtml writes . // Without the document's own font the text takes the one it's shown in. return document.toHtml().replace(QRegularExpression(QStringLiteral("")), QStringLiteral("")); @@ -226,6 +313,7 @@ void Previewer::setIndex(int index, bool report) { const FolderModel::Entry e = m_entries.at(index); m_viewer = viewerFor(e.mime, e.isDir); m_text.clear(); + m_html.clear(); // A folder's count and first pictures, and a text's opening part, are // read off the UI thread: a big folder, or any file on a slow mount, @@ -234,14 +322,15 @@ void Previewer::setIndex(int index, bool report) { const bool count = e.isDir && e.itemCount < 0; const bool text = m_viewer == "text" || m_viewer == "markdown"; if (count || text) { - auto *watcher = new QFutureWatcher>(this); + auto *watcher = new QFutureWatcher>(this); connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation, text] { watcher->deleteLater(); if (generation != *m_reading) return; // Stepped on to another item while this was read. const auto [entry, read] = watcher->result(); m_entries[m_index] = entry; - m_text = read; + m_text = read.value(0); + m_html = read.value(1); // A ".txt" that turns out to be binary is shown as a plain file. if (text && m_text.isEmpty() && entry.size > 0) m_viewer = "other"; @@ -252,11 +341,15 @@ void Previewer::setIndex(int index, bool report) { watcher->setFuture(QtConcurrent::run([entry = e, showHidden, count, text, markdown, latest = m_reading, generation]() mutable { if (*latest != generation) - return qMakePair(entry, QString()); + return qMakePair(entry, QStringList()); if (count) FolderModel::summarize(entry, showHidden); const QString read = text ? readText(entry.path) : QString(); - return qMakePair(entry, markdown ? markdownOpening(read) : read); + if (!markdown) + return qMakePair(entry, QStringList{read}); + // The page is made here too, off the UI thread, at the size the window sets its text in; a file with no text to show gets none. + const QString opening = markdownOpening(read); + return qMakePair(entry, QStringList{opening, opening.isEmpty() ? QString() : markdownHtml(opening, windowPixels)}); })); } emit changed(); diff --git a/src/previewer.h b/src/previewer.h index fc9c8c43..bd5a584c 100644 --- a/src/previewer.h +++ b/src/previewer.h @@ -26,6 +26,7 @@ class Previewer : public QObject { Q_PROPERTY(QString viewer READ viewer NOTIFY changed) Q_PROPERTY(QString url READ url NOTIFY changed) Q_PROPERTY(QString text READ text NOTIFY changed) + Q_PROPERTY(QString html READ html NOTIFY changed) Q_PROPERTY(QString type READ type NOTIFY changed) public: @@ -48,6 +49,8 @@ class Previewer : public QObject { QString viewer() const { return m_viewer; } QString url() const; QString text() const { return m_text; } + // A Markdown file's text as the page to show; empty for any other. + QString html() const { return m_html; } QString type() const; Q_INVOKABLE void next(); @@ -65,8 +68,10 @@ class Previewer : public QObject { // `cells` cells: laid out, each costs the UI thread about as much as a // whole line of prose, and a long table froze the preview for seconds. static QString markdownOpening(const QString &text, int cells = 600); - // Markdown as HTML, each picture replaced by its alt text so showing it loads nothing; raw HTML stays text. - static QString markdownHtml(const QString &markdown); + // Markdown as HTML for text `pixels` high, with room between its blocks; a picture is its alt text, so showing it loads nothing, and raw HTML stays text. + static QString markdownHtml(const QString &markdown, int pixels); + // The sizes the preview window and the details pane's card set their text in, which a Markdown page's room is cut to. + static constexpr int windowPixels = 13, cardPixels = 10; signals: void changed(); @@ -79,6 +84,7 @@ class Previewer : public QObject { int m_index = -1; QString m_viewer; QString m_text; + QString m_html; bool m_showHidden = false; QString m_sort = "name"; quint64 m_generation = 0; diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 99a94b0c..8357a0e8 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -26,6 +26,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -3423,6 +3427,11 @@ private slots: QVERIFY(!backend.details().contains("text")); const QString html = backend.details().value("markdown").toString(); QVERIFY2(!html.contains(" said\n\nA `flag` and a [site](https://example.com) here.\n"; + QTextDocument page; + page.setHtml(Previewer::markdownHtml(markdown, 10)); + const auto block = [&page](const QString &text) { + QTextBlock found = page.begin(); + while (found.isValid() && found.text() != text) + found = found.next(); + return found; + }; + for (const char *text : {"Log", "Prints from the roll.", "Worked", "one", "within", "two", "first of all", "then", "After the lists.", "12", "Dodge the sky.", "Later", "first", "second", "said"}) + QVERIFY2(block(text).isValid(), text); + // The first heading starts the page; a later one stands off what came before, a smaller one by less. + QCOMPARE(block("Log").blockFormat().topMargin(), 0.0); + QCOMPARE(block("Log").blockFormat().bottomMargin(), 6.0); + QCOMPARE(block("Worked").blockFormat().topMargin(), 11.0); + QCOMPARE(block("Later").blockFormat().topMargin(), 8.0); + QCOMPARE(block("Prints from the roll.").blockFormat().bottomMargin(), 8.0); + // A list's items keep close, and what follows the list stands off it. + QCOMPARE(block("one").blockFormat().bottomMargin(), 2.0); + QCOMPARE(block("two").blockFormat().bottomMargin(), 2.0); + QCOMPARE(block("After the lists.").blockFormat().topMargin(), 5.0); + QCOMPARE(block("Dodge the sky.").blockFormat().topMargin(), 0.0); + QVERIFY(block("one").textList()); + // A list inside an item keeps to it, and a second list at the page's edge stands off the first. + QCOMPARE(block("within").blockFormat().topMargin(), 0.0); + QCOMPARE(block("two").blockFormat().topMargin(), 0.0); + QCOMPARE(block("first of all").blockFormat().topMargin(), 5.0); + QCOMPARE(block("then").blockFormat().topMargin(), 0.0); + // A table's cells have room, between quiet rules, and the table stands off the text after it. + const QTextTable *table = QTextCursor(block("12")).currentTable(); + QVERIFY(table); + QCOMPARE(table->format().cellPadding(), 5.0); + // Qt's Markdown reader gives a table its one pixel rule and joins the cells' rules; the look stands on both. + QCOMPARE(table->format().border(), 1.0); + QVERIFY(table->format().borderCollapse()); + QCOMPARE(table->format().borderStyle(), QTextFrameFormat::BorderStyle_Solid); + QCOMPARE(table->format().borderBrush().color().alpha(), 96); + QCOMPARE(table->format().cellSpacing(), 0.0); + QCOMPARE(table->format().topMargin(), 3.0); + QCOMPARE(table->format().bottomMargin(), 9.0); + QCOMPARE(block("12").blockFormat().bottomMargin(), 0.0); + QCOMPARE(block("Dodge the sky.").blockFormat().bottomMargin(), 8.0); + // The lines of a code block touch, on a ground of their own; a quote keeps its own indent. + QCOMPARE(block("first").blockFormat().bottomMargin(), 0.0); + QCOMPARE(block("second").blockFormat().bottomMargin(), 8.0); + QVERIFY(block("first").blockFormat().background().style() != Qt::NoBrush); + QVERIFY(block("Dodge the sky.").blockFormat().background().style() == Qt::NoBrush); + QVERIFY(block("said").blockFormat().leftMargin() > 0); + // Lines of text and of a list stand a little apart; a code block's keep the font's own step. + QCOMPARE(block("Prints from the roll.").blockFormat().lineHeight(), 120.0); + QCOMPARE(block("one").blockFormat().lineHeight(), 120.0); + QCOMPARE(block("first").blockFormat().lineHeight(), 0.0); + // Code in a line stands on the same ground, and a link is underlined text in the text's own color: nothing here follows it. + const QTextBlock line = block("A flag and a site here."); + QVERIFY(line.isValid()); + const auto formatOf = [&line](const QString &word) { + for (QTextBlock::iterator run = line.begin(); !run.atEnd(); ++run) + if (run.fragment().text() == word) + return run.fragment().charFormat(); + return QTextCharFormat(); + }; + QVERIFY(formatOf("flag").background().style() != Qt::NoBrush); + QVERIFY(formatOf("A ").isValid() && formatOf("A ").background().style() == Qt::NoBrush); + QVERIFY(formatOf("site").fontUnderline()); + QVERIFY(!formatOf("site").isAnchor()); + QVERIFY(!formatOf("site").hasProperty(QTextFormat::ForegroundBrush)); + + // A list at the page's edge stands off the one before it also when that one ended on an item further in. + page.setHtml(Previewer::markdownHtml("- outer\n - inner\n\n1. numbered\n", 10)); + QCOMPARE(block("inner").blockFormat().topMargin(), 0.0); + QCOMPARE(block("numbered").blockFormat().topMargin(), 5.0); + // Two code blocks in a row are two when their languages differ; code in a list item and code set in by four spaces get the ground too. + page.setHtml(Previewer::markdownHtml("```sh\nasked\n```\n\n```\nanswered\nagain\n```\n\n- step\n\n ```\n made\n ```\n\nThen:\n\n set in\n", 10)); + QCOMPARE(block("asked").blockFormat().bottomMargin(), 8.0); + QCOMPARE(block("answered").blockFormat().bottomMargin(), 0.0); + for (const char *text : {"asked", "answered", "made", "set in"}) + QVERIFY2(block(text).blockFormat().background().style() != Qt::NoBrush, text); + QCOMPARE(block("made").blockFormat().lineHeight(), 0.0); + + // The room follows the size the text is shown in. + page.setHtml(Previewer::markdownHtml(markdown, 20)); + QCOMPARE(block("Log").blockFormat().bottomMargin(), 11.0); + QCOMPARE(block("Worked").blockFormat().topMargin(), 22.0); + QCOMPARE(QTextCursor(block("12")).currentTable()->format().cellPadding(), 10.0); + } + + void previewShowsMarkdownAsAPage() + { + // The preview window shows a Markdown file as the page made of it, and any other text as written. + QTemporaryDir dir; + write(dir.filePath("a.md"), "# Rows\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nAfter.\n"); + write(dir.filePath("b.txt"), "# Rows\n"); + write(dir.filePath("c.md"), "# Other\n"); + Previewer previewer; + previewer.start(dir.filePath("a.md"), false, "name"); + QTRY_VERIFY(!previewer.html().isEmpty()); + QCOMPARE(previewer.viewer(), QString("markdown")); + QVERIFY(previewer.text().startsWith("# Rows\n")); + QTextDocument page; + page.setHtml(previewer.html()); + const QTextTable *table = nullptr; + for (QTextFrame *frame : page.rootFrame()->childFrames()) + if (auto *found = qobject_cast(frame)) + table = found; + // The cells' room is half the window's text size, rounded: the page was cut for the window, not for the details pane's card. + QVERIFY(table); + QCOMPARE(table->format().cellPadding(), 7.0); + QVERIFY(page.toPlainText().startsWith("Rows")); + QVERIFY(page.toPlainText().contains("After.")); + QVERIFY(!page.toPlainText().contains('|')); + // A step to another Markdown file takes the old page down at once, ahead of the read of the new one. + previewer.show(dir.filePath("c.md")); + QVERIFY(previewer.html().isEmpty()); + QTRY_VERIFY(previewer.html().contains("Other")); + QVERIFY(!previewer.html().contains("After.")); + previewer.show(dir.filePath("b.txt")); + QTRY_COMPARE(previewer.text(), QString("# Rows\n")); + QVERIFY(previewer.html().isEmpty()); + // A binary file named like Markdown is shown as a plain file, with no page of its own. + write(dir.filePath("d.md"), QByteArray("\x00\x01\x02\x03 not text", 13)); + previewer.show(dir.filePath("d.md")); + QTRY_COMPARE(previewer.viewer(), QString("other")); + QVERIFY(previewer.html().isEmpty()); + } + + void previewWindowSetsAMarkdownFileAsItsPage() + { + // The window's text is the page for a Markdown file, in the size the page's room was cut to, and the file as written for any other text. + 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()); + write(home.filePath("a.md"), "# Rows\n\n| a | b |\n|---|---|\n| 1 | 2 |\n"); + write(home.filePath("b.txt"), "# Rows\n"); + Theme theme(home.filePath("theme")); + Translator translator(home.filePath("language"), sourceFolder() + "/i18n"); + Previewer previewer; + previewer.start(home.filePath("a.md"), false, "name"); + QTRY_VERIFY(!previewer.html().isEmpty()); + 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())); + const auto body = [&root] { + QQuickItem *edit = nullptr; + for (QQuickItem *child : root->findChildren()) + if (child->inherits("QQuickTextEdit")) + edit = child; + return edit; + }; + QTRY_VERIFY(body()); + QCOMPARE(body()->property("textFormat").toInt(), int(Qt::RichText)); + QCOMPARE(body()->property("font").value().pixelSize(), Previewer::windowPixels); + // What is drawn is the page: the heading without its mark and the cells without their bars. + QString drawn; + QVERIFY(QMetaObject::invokeMethod(body(), "getText", Q_RETURN_ARG(QString, drawn), Q_ARG(int, 0), Q_ARG(int, body()->property("length").toInt()))); + QVERIFY2(drawn.contains("Rows") && !drawn.contains('#') && !drawn.contains('|') && !drawn.contains('<'), qPrintable(drawn)); + previewer.show(home.filePath("b.txt")); + QTRY_COMPARE(body()->property("text").toString(), QString("# Rows\n")); + QCOMPARE(body()->property("textFormat").toInt(), int(Qt::PlainText)); + } + void backendReadsTrashNotesOffTheUiThread() { // Finding a trashed item's note looks over every mounted drive, so @@ -4535,6 +4719,48 @@ private slots: QTRY_COMPARE(glyph->property("name").toString(), QString("file")); } + void detailsSetAMarkdownFileInTheSizeItsRoomWasCutTo() + { + // The details card draws a Markdown file's page in the size its room was cut to. + QTemporaryDir home; + // 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()); + backend.setPlugins(&plugins); + 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()); + QVERIFY(window); + + // The page is the one rich text inside the pane. + std::function find = [&](QQuickItem *item, bool inPane) -> QQuickItem * { + const QByteArray type = item->metaObject()->className(); + if (inPane && item->inherits("QQuickTextEdit") && item->property("textFormat").toInt() == int(Qt::RichText)) + return item; + for (QQuickItem *child : item->childItems()) + if (QQuickItem *found = find(child, inPane || type.startsWith("PreviewPane"))) + return found; + return nullptr; + }; + QQuickItem *page = find(window->contentItem(), false); + QVERIFY(page); + QCOMPARE(page->property("font").value().pixelSize(), Previewer::cardPixels); + } + void pluginPollsArentHeldUpByActions() { QTemporaryDir folder, home;