From 200931d3601c0a70c0fee35f41ec90d2a074c525 Mon Sep 17 00:00:00 2001 From: Marc Mironescu <100318111+rustytensor@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:45:38 -0500 Subject: [PATCH] Open folders in place in the list view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list can now show a tree: with it on, → opens the folder under the cursor right below it and ← closes it again (or steps out to the folder it's in, and at the top goes up as before). Each open folder's items are ordinary rows, one step further in and joined by thin lines like the tree command draws them, so selection, copy, delete and rename work on them as on any other row. Open folders are listed in the background and watched, so what changes in them shows without the rest of the list jumping, and closing one lets go of anything selected inside it. The tree is off until T is pressed in the list, or the tree button beside the view button is clicked, and the choice is remembered with the view. Off, the list is exactly what it was. Searches, Recents and plugins' views stay flat, and switching to the grid or the columns closes what was open. --- README.md | 3 + src/FileRow.qml | 94 ++++- src/FolderList.qml | 104 ++++- src/FolderPane.qml | 41 +- src/Glyph.qml | 3 + src/HelpOverlay.qml | 2 + src/Main.qml | 3 + src/PathBar.qml | 16 + src/foldermodel.cpp | 323 ++++++++++++++- src/foldermodel.h | 59 +++ src/i18n/en.json | 7 + src/windows.cpp | 6 +- src/windows.h | 5 +- tests/folio_tests.cpp | 901 ++++++++++++++++++++++++++++++++++++++++++ 14 files changed, 1535 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index ef59b417..455daa49 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Files (`folio`) is Omarchy's file manager. It's one quiet window that follows your Omarchy theme and language, works from the keyboard, and never crawls your home folder in the background: folders are read when you open them, and thumbnails are made when a tile needs one, shared with every other app through `~/.cache/thumbnails`. - **Three views**: a grid with real previews, a list, and Finder-style columns. Ctrl+1/2/3 switches. + The list can show a tree, where → and ← open folders in place; it's off until T (or the tree button beside the view button). - **Space previews** pictures, video, audio, PDFs, text and Markdown at full size, in their own window. - **The sidebar** holds everything in your home folder, pinned folders (shared with GTK file dialogs), Recents, Trash with restore, your drives, and the machines on your tailnet, which open over SFTP. - **Right-click** (or Alt+Enter) to open with another app, rename, duplicate, compress, share by Dropbox link, Taildrop or LocalSend, and delete. Copy and paste work with other apps, and nothing is ever overwritten. @@ -20,6 +21,7 @@ Files (`folio`) is Omarchy's file manager. It's one quiet window that follows yo | Key | Does | | --- | --- | | Arrows, ⏎ | Move, open | +| → / ← in the list, with the tree on | Open a folder in place, close it | | Space | Preview | | Backspace, Alt+← / Alt+→ | Up, back, forward | | Ctrl+L | Type a path | @@ -28,6 +30,7 @@ Files (`folio`) is Omarchy's file manager. It's one quiet window that follows yo | F2, Delete | Rename, move to the trash | | Ctrl+Shift+N | New folder | | Ctrl+P | Pin the folder | +| T | Tree in the list | | Ctrl+N, Ctrl+Alt+N | New window, split | | Tab, Shift+Tab | Sidebar, panes and details | | ? | Every other shortcut | diff --git a/src/FileRow.qml b/src/FileRow.qml index a8dab284..119158a3 100644 --- a/src/FileRow.qml +++ b/src/FileRow.qml @@ -20,15 +20,24 @@ Item { required property int itemCount required property string icon required property bool selected + required property int depth + required property bool open + required property string lines property bool current: false // In a column to the left: the folder the next column is showing. property bool trail: false property bool detailed: false property bool chevron: false + // In the list with the tree on: rows indented by how deep in the tree + // they are, joined by thin lines to the folder they're in, and an arrow + // on folders to open them in place. Off, the row is as it always was. + property bool tree: false signal clicked(int modifiers) signal activated() + // The arrow was clicked (or pressed by a screen reader). + signal toggled() implicitHeight: 32 @@ -69,12 +78,83 @@ Item { : "transparent" } + // The lines, one 16 px step for each level in, like the tree command + // draws them: the vertical ones run the row's full height so they join + // the rows above and below. Drawn left to right and flipped whole when + // mirrored, so they lead from the arrows on the other side. + Item { + objectName: "treeLines" + visible: row.tree && row.depth > 0 + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.top: parent.top + anchors.bottom: parent.bottom + width: row.depth * 16 + 16 + transform: Scale { xScale: row.LayoutMirroring.enabled ? -1 : 1; origin.x: (row.depth * 16 + 16) / 2 } + Repeater { + model: row.tree ? row.lines.split("") : [] + Item { + required property int index + required property string modelData + readonly property bool elbow: modelData === "├" || modelData === "└" + x: index * 16 + width: 16 + height: parent.height + Rectangle { + objectName: "treeGuide" + visible: modelData !== " " + x: 7 + width: 1 + height: modelData === "└" ? Math.round(parent.height / 2) : parent.height + color: Qt.alpha(theme.foreground, 0.25) + } + // Over to this row's arrow, or for a file, which has none, on + // through the arrow's room to its icon. + Rectangle { + visible: parent.elbow + x: 8 + y: Math.round(parent.height / 2) + width: row.isDir ? 6 : 20 + height: 1 + color: Qt.alpha(theme.foreground, 0.25) + } + } + } + } + RowLayout { anchors.fill: parent anchors.leftMargin: 14 anchors.rightMargin: 14 spacing: 12 + // One step in for each open folder above, then the arrow. A file has + // the same room without one, so the names line up. + Item { + visible: row.tree + implicitWidth: row.depth * 16 + 16 + implicitHeight: 16 + Item { + id: disclosure + objectName: "disclosure" + visible: row.isDir + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 16 + height: 16 + Accessible.role: Accessible.Button + Accessible.name: row.open ? qsTr("Collapse %1").arg(row.name) : qsTr("Expand %1").arg(row.name) + Accessible.onPressAction: row.toggled() + // Points along the row, and down once the folder is open. + Glyph { + anchors.centerIn: parent + name: row.LayoutMirroring.enabled ? "back" : "forward" + rotation: row.open ? (row.LayoutMirroring.enabled ? -90 : 90) : 0 + size: 12 + color: theme.secondaryForeground + } + } + } Item { implicitWidth: 20 implicitHeight: 20 @@ -113,6 +193,7 @@ 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 { + objectName: "rowName" Layout.fillWidth: true Layout.minimumWidth: 120 horizontalAlignment: Text.AlignLeft @@ -154,10 +235,19 @@ Item { id: hover onHoveredChanged: if (hovered) row.grabToImage(result => row.Drag.imageSource = result.url) } + // A click on the arrow (or a little either side of it, the row's full + // height) only opens or closes the folder: one handler for the whole + // row, so the click is never taken as both. + function atArrow(position) { + if (!row.tree || !disclosure.visible) + return false + const at = disclosure.mapFromItem(row, position.x, position.y) + return at.x >= -6 && at.x < disclosure.width + 6 + } TapHandler { id: tap - onTapped: row.clicked(tap.point.modifiers) - onDoubleTapped: row.activated() + onTapped: row.atArrow(tap.point.position) ? row.toggled() : row.clicked(tap.point.modifiers) + onDoubleTapped: if (!row.atArrow(tap.point.position)) row.activated() } // Like the tiles: dragged, a row is a file for the sidebar or another app. diff --git a/src/FolderList.qml b/src/FolderList.qml index 8e93a633..1ca1712d 100644 --- a/src/FolderList.qml +++ b/src/FolderList.qml @@ -6,6 +6,13 @@ import QtQuick.Controls ListView { id: list + // Whether folders open in place (the window's tree, T). Off, the + // list is a plain list: → and ← go into and out of folders, as in the + // columns. Turned off, the open folders close, and the rows shifting + // put the cursor on the folder at the top its item was in. + property bool tree: false + onTreeChanged: if (!tree) backend.folder.collapseAll() + 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. @@ -17,6 +24,50 @@ ListView { Selecting { id: selecting; view: list } onCurrentIndexChanged: selecting.cursorMoved() + + // Rows coming and going in place (a folder opened or closed above or + // around the cursor, its items arriving, a file appearing, a new order) + // would move the cursor off its item, and the cursor moving selects + // what it lands on. So the rows are laid out at once, quietly, and the + // cursor put back on its item; when that went out of sight with a + // folder closed, on the folder, which is then selected if nothing is. + property string shiftingPath: "" + property string shiftingAnchor: "" + Connections { + target: backend.folder + function onRowsShifting() { + list.shiftingPath = backend.folder.get(list.currentIndex).path || "" + list.shiftingAnchor = backend.folder.get(selecting.anchor).path || "" + } + function onRowsShifted() { + const folder = backend.folder + selecting.quiet = true + list.forceLayout() + selecting.quiet = false + if (!list.shiftingPath) + return + const shown = folder.path.endsWith("/") ? folder.path : folder.path + "/" + let row = folder.indexOfPath(list.shiftingPath) + const hidden = row < 0 + for (let up = list.shiftingPath; row < 0 && up.startsWith(shown);) { + up = up.substring(0, up.lastIndexOf("/")) + row = folder.indexOfPath(up) + } + // Gone from the folder itself (trashed, moved away): the cursor + // stays on its row, and the folder's re-read places it as before. + if (row < 0) + return + if (row !== list.currentIndex) + selecting.moveTo(row) + if (hidden) { + selecting.anchor = row + if (folder.selectedCount === 0) + folder.selectOnly(row) + } else { + selecting.anchor = folder.indexOfPath(list.shiftingAnchor) + } + } + } Keys.onPressed: (event) => selecting.extendBy(event, event.key === Qt.Key_Up ? -1 : event.key === Qt.Key_Down ? 1 : 0) Band { @@ -24,15 +75,46 @@ ListView { rectOf: (i) => Qt.rect(0, i * 32, list.width, 32) } - // → goes into the selected folder and ← back up, as in the columns; - // mirrored, the other way round, with the reading direction. - function enter() { - const item = backend.folder.get(currentIndex) - if (item.isDir) + // With the tree on, → opens the cursor's folder in place, below it, and on an open one + // steps to its first item; ← closes it, or steps out to the folder it's + // in, and at the top goes back up as before. Mirrored, the other way + // round, with the reading direction. Where nothing nests (a search, + // Recents, a plugin's view) → goes into the folder, as in the columns. + function inward() { + const folder = backend.folder + const item = folder.get(currentIndex) + if (!item.isDir) + return + if (!tree) { backend.navigate(item.path) + } else if (item.open) { + // Its items may still be on their way; until they're there, the + // cursor stays put. + if (currentIndex + 1 < count && folder.get(currentIndex + 1).depth > item.depth) + moveCursor(currentIndex + 1) + } else if (!folder.expand(currentIndex)) { + backend.navigate(item.path) + } + } + function outward() { + const folder = backend.folder + const item = folder.get(currentIndex) + if (!tree) + backend.goUp() + else if (item.open) + folder.collapse(currentIndex) + else if (folder.parentRow(currentIndex) >= 0) + moveCursor(folder.parentRow(currentIndex)) + else + backend.goUp() + } + // Moved like the arrows move it, so what it lands on is selected. + function moveCursor(row) { + currentIndex = row + positionViewAtIndex(row, ListView.Contain) } - Keys.onRightPressed: LayoutMirroring.enabled ? backend.goUp() : enter() - Keys.onLeftPressed: LayoutMirroring.enabled ? enter() : backend.goUp() + Keys.onRightPressed: LayoutMirroring.enabled ? outward() : inward() + Keys.onLeftPressed: LayoutMirroring.enabled ? inward() : outward() // Right-click on an item (selecting it) or on empty space. signal contextMenu(var item, string folder, point at) @@ -56,8 +138,16 @@ ListView { delegate: FileRow { width: list.width detailed: true + tree: list.tree current: selected onClicked: (modifiers) => { selecting.click(index, modifiers); list.forceActiveFocus() } + // The arrow opens or closes the folder in place, leaving the folder + // shown and the selection as they are. + onToggled: { + if (list.tree) + open ? backend.folder.collapse(index) : backend.folder.expand(index) + list.forceActiveFocus() + } onActivated: backend.open(path) } diff --git a/src/FolderPane.qml b/src/FolderPane.qml index 1c3d5315..472c96c4 100644 --- a/src/FolderPane.qml +++ b/src/FolderPane.qml @@ -26,7 +26,40 @@ FocusScope { // remembered between launches; the right pane of a split starts with // the view of the pane it was split from. property string viewMode: host ? host.viewMode : "columns" - onViewModeChanged: if (host && !closable) host.viewMode = viewMode + onViewModeChanged: { + if (host && !closable) + host.viewMode = viewMode + if (viewMode !== "list") + leaveTree() + } + // Folders open in place are the list's own: the grid and the columns + // show the folder's items alone, so switching away closes them. The + // cursor goes to the folder its item was in, at the top. + function leaveTree() { + let row = backend.folder.indexOfPath(lastPath) + while (row >= 0 && backend.folder.parentRow(row) >= 0) + row = backend.folder.parentRow(row) + const path = row >= 0 ? backend.folder.get(row).path : lastPath + const rows = backend.folder.count + backend.folder.collapseAll() + if (backend.folder.count === rows) + return + lastPath = path + if (view.item && !cursorLetGo) + view.item.select(backend.folder.indexOfPath(path), true) + } + // Whether the list opens folders in place, shared by the window's panes + // and remembered with the view. + readonly property bool treeView: host ? !!host.treeView : false + // T and the tree button. The key says what it did in the banner, as + // the button can't be seen to light from the keyboard's side. + function toggleTree(say) { + if (!host) + return + host.treeView = !host.treeView + if (say) + backend.showNotice(host.treeView ? qsTr("Tree on") : qsTr("Tree off")) + } // How big the grid's tiles are, in steps (Ctrl+= / Ctrl+- / Ctrl+0, or // Ctrl+scroll), shared and remembered the same way as the view. property real zoom: host ? host.zoom : 1 @@ -198,6 +231,8 @@ FocusScope { // S steps through the sort orders, Shift+S back. Shortcut { sequence: "S"; enabled: pane.keys; onActivated: pathBar.stepSort(1) } Shortcut { sequence: "Shift+S"; enabled: pane.keys; onActivated: pathBar.stepSort(-1) } + // T turns the list's tree on and off; the grid and the columns have none. + Shortcut { sequence: "T"; enabled: pane.keys && pane.viewMode === "list"; onActivated: pane.toggleTree(true) } Shortcut { sequences: ["F5", "Refresh"]; enabled: pane.active; onActivated: { backend.folder.reload(); places.refresh(); plugins.refresh() } } // A drive being ejected can't be in use: a pane showing a folder on it @@ -413,7 +448,7 @@ FocusScope { } } Component { id: gridView; FolderGrid { zoom: pane.zoom; onZoomRequested: (step) => pane.zoomBy(step) } } - Component { id: listView; FolderList { } } + Component { id: listView; FolderList { tree: pane.treeView } } Component { id: columnsView; FolderColumns { } } } @@ -431,7 +466,9 @@ FocusScope { viewMode: pane.viewMode closable: pane.closable onTogglePreview: if (pane.host) pane.host.previewVisible = !pane.host.previewVisible + treeView: pane.treeView onCycleView: pane.cycleView() + onTreeToggled: pane.toggleTree(false) onCloseRequested: pane.closeRequested() } diff --git a/src/Glyph.qml b/src/Glyph.qml index dc33af44..005f4041 100644 --- a/src/Glyph.qml +++ b/src/Glyph.qml @@ -70,6 +70,9 @@ Item { 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" case "columns": return box(3, 4, 18, 16, r) + "M9 4v16M15 4v16" + // A folder's row, and under it two rows open in place, joined by + // the tree's lines. + case "tree": return "M4 6h10M7 6v12h4M7 12h4M14 12h6M14 18h6" case "preview": return box(3, 5, 18, 14, r) + "M14 5v14" // 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, diff --git a/src/HelpOverlay.qml b/src/HelpOverlay.qml index 388be554..c2f6728a 100644 --- a/src/HelpOverlay.qml +++ b/src/HelpOverlay.qml @@ -32,6 +32,7 @@ Rectangle { property var shortcuts: [ ["← ↑ → ↓", qsTr("Move the selection")], [LayoutMirroring.enabled ? "← / →" : "→ / ←", qsTr("In list and columns: into a folder / back out")], + [LayoutMirroring.enabled ? "← / →" : "→ / ←", qsTr("With the tree on: open a folder in place / close it or step out")], // The keys in these few are mixed with words; the key names (Shift, // Alt, ⏎) stay as they are in every language. [qsTr("Shift+click / Shift+arrows"), qsTr("Select a range")], @@ -63,6 +64,7 @@ Rectangle { ["Escape", qsTr("Stop the file work under way, or dismiss what went wrong")], [qsTr("Right-click / Alt+⏎ / Menu"), qsTr("Everything else, for files and for drives in the sidebar")], ["S / Shift+S", qsTr("Sort: name, kind, newest, oldest, size")], + ["T", qsTr("In the list: the tree, folders opening in place, on or off")], ["Ctrl+H", qsTr("Show hidden files")], ["F5", qsTr("Reload")], ["?", qsTr("Show these shortcuts")], diff --git a/src/Main.qml b/src/Main.qml index ff8f4221..1d977597 100644 --- a/src/Main.qml +++ b/src/Main.qml @@ -57,6 +57,8 @@ ApplicationWindow { // Columns on a first launch; after that, whichever view was used last. property string viewMode: "columns" property real zoom: 1 + // Folders open in place in the list view, off until turned on. + property bool treeView: false // The pane keys, the sidebar and the details go to. property var activePane: leftPane @@ -80,6 +82,7 @@ ApplicationWindow { Settings { property alias viewMode: win.viewMode property alias zoom: win.zoom + property alias treeView: win.treeView } // Ctrl+Alt+N: a second pane on the right, starting where the active diff --git a/src/PathBar.qml b/src/PathBar.qml index 47c25d28..be3c3a8f 100644 --- a/src/PathBar.qml +++ b/src/PathBar.qml @@ -9,6 +9,8 @@ Rectangle { property bool previewVisible: true property bool previewFits: true property string viewMode: "grid" + // Whether the list opens folders in place: the window's, shown here. + property bool treeView: false // Ctrl+L: the path as text to edit. Enter goes there (or connects to a // server's address), Escape cancels, Tab completes a folder name. property bool editing: false @@ -17,6 +19,7 @@ Rectangle { signal closeRequested() signal togglePreview() signal cycleView() + signal treeToggled() signal editDone() function editPath() { @@ -190,6 +193,7 @@ Rectangle { Item { implicitWidth: 16 } // Shows the current view; clicking moves to the next one. IconButton { + objectName: "viewButton" glyph: bar.viewMode // Names what a click does, like the buttons beside it. text: bar.viewMode === "grid" ? qsTr("Show as list") @@ -197,6 +201,18 @@ Rectangle { : qsTr("Show as grid") onClicked: bar.cycleView() } + // In the list: whether folders open in place (T), lit while they do. + IconButton { + objectName: "treeButton" + visible: bar.viewMode === "list" + glyph: "tree" + text: qsTr("Tree") + active: bar.treeView + checked: bar.treeView + Accessible.checkable: true + Accessible.checked: bar.treeView + onClicked: bar.treeToggled() + } IconButton { id: sortButton // Shows the order in use, and says it on hover. diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 2cce5a3f..c12762cb 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include FolderModel::FolderModel(QObject *parent) : QAbstractListModel(parent) { @@ -20,7 +21,15 @@ FolderModel::FolderModel(QObject *parent) : QAbstractListModel(parent) { // burst to settle instead of relisting on every event. m_debounce.setSingleShot(true); m_debounce.setInterval(250); - connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, [this] { m_debounce.start(); }); + // An open folder in the tree is re-read on its own, not with the folder. + connect(&m_watcher, &QFileSystemWatcher::directoryChanged, this, [this](const QString &dir) { + if (m_childWatched.contains(dir)) { + m_childDirty.insert(dir); + m_childDebounce.start(); + } else { + m_debounce.start(); + } + }); // Recents' file is rewritten (replaced) by GTK, so it's watched again // after each change. connect(&m_watcher, &QFileSystemWatcher::fileChanged, this, [this](const QString &file) { @@ -29,6 +38,14 @@ FolderModel::FolderModel(QObject *parent) : QAbstractListModel(parent) { m_watcher.addPath(file); }); connect(&m_debounce, &QTimer::timeout, this, &FolderModel::reload); + m_childDebounce.setSingleShot(true); + m_childDebounce.setInterval(250); + connect(&m_childDebounce, &QTimer::timeout, this, [this] { + const QSet dirty = std::exchange(m_childDirty, {}); + for (const QString &folder : dirty) + if (m_childWatched.contains(folder)) + listChildren(folder); + }); m_staleClear.setSingleShot(true); m_staleClear.setInterval(150); connect(&m_staleClear, &QTimer::timeout, this, &FolderModel::clearEntries); @@ -38,6 +55,7 @@ FolderModel::FolderModel(QObject *parent) : QAbstractListModel(parent) { // running for this model. FolderModel::~FolderModel() { *m_latest = 0; + *m_treeLatest = 0; } // Recent listings, shared by every model (the window's folder and each @@ -58,27 +76,32 @@ void FolderModel::remember() { // Search results are a moment's question, not a folder to come back to. if (!m_query.isEmpty()) return; - recentListings().insert(cacheKey(), new QVector(m_entries)); + // The folder's own items: open folders are closed on coming back. + recentListings().insert(cacheKey(), new QVector(m_top)); +} + +// By path, not name: a folder copied with cp -a or rsync lists the same +// names, sizes and times as the one it came from. Name, subtitle, icon and +// details too: a plugin's item keeps its path (its id) when they change. +static bool sameEntry(const FolderModel::Entry &a, const FolderModel::Entry &b) { + return a.path == b.path && a.size == b.size && a.modified == b.modified && a.isDir == b.isDir + && a.name == b.name && a.extension == b.extension && a.icon == b.icon && a.details == b.details + && a.depth == b.depth && a.lines == b.lines; } bool FolderModel::sameListing(const QVector &a, const QVector &b) { if (a.size() != b.size()) return false; - // By path, not name: a folder copied with cp -a or rsync lists the same - // names, sizes and times as the one it came from. for (int i = 0; i < a.size(); ++i) - // Name, subtitle, icon and details too: a plugin's item keeps its - // path (its id) when they change. - if (a[i].path != b[i].path || a[i].size != b[i].size || a[i].modified != b[i].modified - || a[i].isDir != b[i].isDir || a[i].name != b[i].name || a[i].extension != b[i].extension - || a[i].icon != b[i].icon || a[i].details != b[i].details) + if (!sameEntry(a[i], b[i])) return false; return true; } void FolderModel::setEntries(const QVector &entries) { beginResetModel(); - m_entries = entries; + m_top = entries; + m_entries = flatten(entries); endResetModel(); emit countChanged(); } @@ -109,6 +132,9 @@ 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 DepthRole: return e.depth; + case OpenRole: return e.isDir && m_open.contains(e.path); + case LinesRole: return e.lines; } return {}; } @@ -118,7 +144,8 @@ 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"}, {DepthRole, "depth"}, {OpenRole, "open"}, + {LinesRole, "lines"}, }; } @@ -143,6 +170,8 @@ void FolderModel::setQuery(const QString &query) { return; m_query = query; emit queryChanged(); + // Search results are flat, and the folder comes back closed. + clearTree(); setSelected({}); // Back to the folder: its remembered listing shows at once. if (m_query.isEmpty()) { @@ -166,6 +195,7 @@ void FolderModel::setPath(const QString &path) { const QString clean = path.isEmpty() || isView(path) ? path : QDir::cleanPath(path); if (clean == m_path) return; + clearTree(); if (!m_watcher.directories().isEmpty()) m_watcher.removePaths(m_watcher.directories()); if (!m_watcher.files().isEmpty()) @@ -443,6 +473,10 @@ void FolderModel::reload() { return; const quint64 generation = ++m_generation; setLoading(true); + // Open folders are re-read too: a new order or hidden files show in + // them as well. + for (const QString &folder : QSet(m_childWatched)) + listChildren(folder); const QString path = m_path; const bool showHidden = m_showHidden; @@ -461,9 +495,16 @@ void FolderModel::reload() { const auto [entries, error] = watcher->result(); // Unchanged (the usual case after showing a remembered listing): // leave the items alone, and only fill in folder counts still missing. - const bool same = error == m_error && !m_entries.isEmpty() && sameListing(m_entries, entries); - if (!same) + const QVector rows = flatten(entries); + const bool same = error == m_error && !m_entries.isEmpty() && sameListing(m_entries, rows); + // With folders open, only what changed moves, so the tree doesn't + // jump back to the top. + if (!same && rows.size() != entries.size()) { + m_top = entries; + showRows(rows); + } else if (!same) { setEntries(entries); + } if (!m_selected.isEmpty()) { QSet still; for (const Entry &e : m_entries) @@ -479,6 +520,7 @@ void FolderModel::reload() { emit loaded(); if (error.isEmpty()) remember(); + followOpenFolders(); countFolders(generation); }); watcher->setFuture(QtConcurrent::run([path, showHidden, sort, query, items, latest, generation, locale] { @@ -507,9 +549,32 @@ void FolderModel::countFolders(quint64 generation) { watcher->deleteLater(); if (generation != m_generation) return; + // By path: a folder opened or closed meanwhile moves the rows. bool updated = false; - for (const auto &[row, summary] : watcher->result()) { - if (row >= m_entries.size() || m_entries[row].path != summary.path) + QHash rows, tops; + for (int row = 0; row < m_entries.size(); ++row) + rows.insert(m_entries[row].path, row); + for (int top = 0; top < m_top.size(); ++top) + tops.insert(m_top[top].path, top); + // An open folder's items keep their counts too, or the next time + // the tree is put together they'd come back uncounted. + QHash> nested; + for (const auto &[unused, summary] : watcher->result()) { + if (const int top = tops.value(summary.path, -1); top >= 0) + m_top[top].itemCount = summary.itemCount; + const QString parent = QFileInfo(summary.path).path(); + if (auto listed = m_listed.find(parent); listed != m_listed.end()) { + if (!nested.contains(parent)) { + QHash at; + for (int i = 0; i < listed->size(); ++i) + at.insert(listed->at(i).path, i); + nested.insert(parent, at); + } + if (const int i = nested[parent].value(summary.path, -1); i >= 0) + (*listed)[i].itemCount = summary.itemCount; + } + const int row = rows.value(summary.path, -1); + if (row < 0) continue; m_entries[row].itemCount = summary.itemCount; const QModelIndex at = index(row); @@ -542,6 +607,7 @@ QVariantMap FolderModel::get(int row) const { const Entry &e = m_entries.at(row); QVariantMap map = toMap(e); map.insert("selected", m_selected.contains(e.path)); + map.insert("open", e.isDir && m_open.contains(e.path)); return map; } @@ -550,7 +616,7 @@ QVariantMap FolderModel::toMap(const Entry &entry) { {"name", entry.name}, {"path", entry.path}, {"isDir", entry.isDir}, {"size", entry.size}, {"modified", entry.modified}, {"kind", entry.kind}, {"mime", entry.mime}, {"extension", entry.extension}, {"itemCount", entry.itemCount}, {"icon", entry.icon}, - {"details", entry.details}, + {"details", entry.details}, {"depth", entry.depth}, {"lines", entry.lines}, }; } @@ -620,9 +686,11 @@ QStringList FolderModel::selectedPaths() const { return paths; } +// Only the folder's own items: the name is always of something in it (a file +// pasted, a folder made, a name given), never the same name in an open folder. int FolderModel::indexOf(const QString &name) const { for (int row = 0; row < m_entries.size(); ++row) - if (m_entries[row].name == name) + if (m_entries[row].depth == 0 && m_entries[row].name == name) return row; return -1; } @@ -647,3 +715,224 @@ void FolderModel::setError(const QString &error) { m_error = error; emit errorChanged(); } + + +// Only a folder's items can be folders to open: a search lists matches from +// all over, and Recents and plugins' views aren't folders. +bool FolderModel::canNest() const { + return m_query.isEmpty() && !m_path.isEmpty() && !isView(m_path) && !m_path.startsWith("plugin:"); +} + +QVector FolderModel::flatten(const QVector &top) const { + if (m_open.isEmpty()) + return top; + QVector rows; + rows.reserve(top.size()); + addRows(rows, top, 0); + return rows; +} + +// `guides` holds the lines of the folders this one is in: a row's own lines +// are those, then its elbow, and its items inherit them with a line of +// their own carrying on below it only if more rows follow it here. +void FolderModel::addRows(QVector &rows, const QVector &entries, int depth, const QString &guides) const { + for (int i = 0; i < entries.size(); ++i) { + const Entry &entry = entries[i]; + const bool last = i == entries.size() - 1; + rows.push_back(entry); + rows.back().depth = depth; + rows.back().lines = depth == 0 ? QString() : guides + (last ? u'└' : u'├'); + // An open folder not listed yet shows open, with its items to come. + if (entry.isDir && m_open.contains(entry.path)) { + const auto listed = m_listed.constFind(entry.path); + if (listed != m_listed.constEnd()) + addRows(rows, *listed, depth + 1, depth == 0 ? QString() : guides + (last ? u' ' : u'│')); + } + } +} + +// Moves from the rows shown to `rows` by removing and inserting only the +// stretch between what they start and end with in common, so a view keeps +// its place and its current item, then updates rows whose details changed. +void FolderModel::showRows(const QVector &rows) { + const auto same = [](const Entry &a, const Entry &b) { return a.path == b.path && a.depth == b.depth; }; + const int before = m_entries.size(); + int head = 0; + while (head < before && head < rows.size() && same(m_entries[head], rows[head])) + ++head; + int tail = 0; + while (tail < before - head && tail < rows.size() - head + && same(m_entries[before - 1 - tail], rows[rows.size() - 1 - tail])) + ++tail; + const bool shifting = before - head - tail > 0 || rows.size() - head - tail > 0; + if (shifting) + emit rowsShifting(); + if (before - head - tail > 0) { + beginRemoveRows({}, head, before - tail - 1); + m_entries.remove(head, before - head - tail); + endRemoveRows(); + } + if (rows.size() - head - tail > 0) { + beginInsertRows({}, head, rows.size() - tail - 1); + // Put together in one go: inserted one at a time, every row below + // would move once per row inserted, and a big folder opened above + // a long list would hold the window up for seconds. + QVector spliced; + spliced.reserve(rows.size()); + spliced.append(m_entries.mid(0, head)); + spliced.append(rows.mid(head, rows.size() - head - tail)); + spliced.append(m_entries.mid(head)); + m_entries = std::move(spliced); + endInsertRows(); + } + bool changed = before != m_entries.size(); + for (int row = 0; row < m_entries.size(); ++row) { + if (sameEntry(m_entries[row], rows[row]) && m_entries[row].itemCount == rows[row].itemCount) + continue; + m_entries[row] = rows[row]; + emit dataChanged(index(row), index(row)); + changed = true; + } + // What's no longer showing (in a folder closed or gone) isn't selected. + if (!m_selected.isEmpty()) { + QSet still; + for (const Entry &e : m_entries) + if (m_selected.contains(e.path)) + still.insert(e.path); + if (still != m_selected) { + m_selected = still; + emit selectionChanged(); + } + } + // QML re-reads its get() snapshots on countChanged. + if (changed) + emit countChanged(); + if (shifting) + emit rowsShifted(); +} + +void FolderModel::showTree() { + showRows(flatten(m_top)); + followOpenFolders(); +} + +// Watches and lists the open folders showing, and stops watching the ones +// that went out of sight (closed, or inside one that was). +void FolderModel::followOpenFolders() { + QSet showing; + for (const Entry &e : m_entries) + if (e.isDir && m_open.contains(e.path)) + showing.insert(e.path); + for (const QString &folder : QSet(m_childWatched)) + if (!showing.contains(folder)) + unwatchChild(folder); + for (const QString &folder : showing) + if (!m_childWatched.contains(folder)) + listChildren(folder); +} + +void FolderModel::unwatchChild(const QString &folder) { + m_childWatched.remove(folder); + m_childDirty.remove(folder); + m_childListing.remove(folder); + if (m_watcher.directories().contains(folder)) + m_watcher.removePath(folder); +} + +void FolderModel::listChildren(const QString &folder) { + if (!m_childWatched.contains(folder)) { + m_childWatched.insert(folder); + m_watcher.addPath(folder); + } + const quint64 listing = ++m_childSerial; + m_childListing.insert(folder, listing); + + const bool showHidden = m_showHidden; + const QString sort = m_sort; + const QLocale locale; + const auto latest = m_treeLatest; + const quint64 tree = *m_treeLatest; + auto *watcher = new QFutureWatcher, QString>>(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, folder, listing] { + watcher->deleteLater(); + if (m_childListing.value(folder) != listing) + return; // Re-read again since, closed, or the tree was cleared. + m_childListing.remove(folder); + const auto [entries, error] = watcher->result(); + if (error.isEmpty()) { + m_listed.insert(folder, entries); + } else { + // Gone or shut: it closes, with nothing inside to show. + m_open.remove(folder); + m_listed.remove(folder); + unwatchChild(folder); + if (const int row = indexOfPath(folder); row >= 0) + emit dataChanged(index(row), index(row), {OpenRole}); + } + showTree(); + // Its items show first and its folders are counted after, as for + // the folder's own: thousands of folders on a slow drive would + // otherwise keep it open and empty for as long as counting takes. + countFolders(m_generation); + }); + watcher->setFuture(QtConcurrent::run([folder, showHidden, sort, locale, latest, tree] { + QString error; + const auto overtaken = [&] { return *latest != tree; }; + auto entries = list(folder, showHidden, sort, &error, overtaken, locale); + return qMakePair(entries, error); + })); +} + +void FolderModel::clearTree() { + for (const QString &folder : QSet(m_childWatched)) + unwatchChild(folder); + m_childListing.clear(); + m_childDirty.clear(); + m_listed.clear(); + m_open.clear(); + ++*m_treeLatest; +} + +bool FolderModel::expand(int row) { + if (row < 0 || row >= m_entries.size() || !canNest()) + return false; + const Entry &e = m_entries[row]; + if (!e.isDir || m_open.contains(e.path)) + return false; + m_open.insert(e.path); + emit dataChanged(index(row), index(row), {OpenRole}); + // What was listed before shows at once (the folders open inside it + // too); the folder is read again in the background either way. + showTree(); + return true; +} + +bool FolderModel::collapse(int row) { + if (row < 0 || row >= m_entries.size()) + return false; + const QString path = m_entries[row].path; + if (!m_entries[row].isDir || !m_open.remove(path)) + return false; + emit dataChanged(index(row), index(row), {OpenRole}); + showTree(); + return true; +} + +void FolderModel::collapseAll() { + if (m_open.isEmpty()) + return; + clearTree(); + showRows(m_top); + if (!m_entries.isEmpty()) + emit dataChanged(index(0), index(m_entries.size() - 1), {OpenRole}); +} + +int FolderModel::parentRow(int row) const { + if (row < 0 || row >= m_entries.size()) + return -1; + const int depth = m_entries[row].depth; + for (int above = row - 1; above >= 0; --above) + if (m_entries[above].depth < depth) + return above; + return -1; +} diff --git a/src/foldermodel.h b/src/foldermodel.h index 681a2c7e..6888c83e 100644 --- a/src/foldermodel.h +++ b/src/foldermodel.h @@ -46,6 +46,16 @@ class FolderModel : public QAbstractListModel { ItemCountRole, SelectedRole, IconRole, + // How far down the list view's tree a row is (0 for the folder's own + // items), and whether a folder row is open in it. + DepthRole, + OpenRole, + // The lines joining a row to the folders it's in, like the tree + // command draws them: one character for each level from 1 to its + // depth, '│' where an outer folder's line carries on past the row + // and ' ' where it doesn't, then '├' when more follow in the same + // folder or '└' for its last item. Empty for the folder's own items. + LinesRole, }; struct Entry { @@ -64,6 +74,10 @@ class FolderModel : public QAbstractListModel { int itemCount = -1; // The first pictures in a folder, for the preview window. QStringList peek; + // 0 for the folder's own items, one more for each open folder above. + int depth = 0; + // Its tree lines, as LinesRole gives them. + QString lines; }; explicit FolderModel(QObject *parent = nullptr); @@ -94,6 +108,18 @@ class FolderModel : public QAbstractListModel { // share a name. Q_INVOKABLE int indexOfPath(const QString &path) const; + // The list view's tree: an open folder's items are rows of their own, + // right below it, so selection, get() and file operations treat them + // like any other. Open folders are listed in the background and + // watched. Only a folder nests: searches, Recents and plugins' views + // stay flat. expand() and collapse() say whether anything changed. + Q_INVOKABLE bool expand(int row); + Q_INVOKABLE bool collapse(int row); + // Closes every folder and forgets which were open. + Q_INVOKABLE void collapseAll(); + // The open folder a row is in, or -1 for the folder's own items. + Q_INVOKABLE int parentRow(int row) 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. int selectedCount() const { return m_selected.size(); } @@ -154,6 +180,11 @@ class FolderModel : public QAbstractListModel { void itemsChanged(); void selectionChanged(); void loaded(); + // Around rows coming and going in place (a folder opened or closed, its + // items arriving, a file appearing in it): a view puts its cursor back + // on its item, which the rows moving around it would take it off. + void rowsShifting(); + void rowsShifted(); private: void setLoading(bool loading); @@ -164,9 +195,36 @@ class FolderModel : public QAbstractListModel { static bool sameListing(const QVector &a, const QVector &b); void setError(const QString &error); void countFolders(quint64 generation); + bool canNest() const; + QVector flatten(const QVector &top) const; + void addRows(QVector &rows, const QVector &entries, int depth, const QString &guides = {}) const; + void showRows(const QVector &rows); + void showTree(); + void followOpenFolders(); + void listChildren(const QString &folder); + void unwatchChild(const QString &folder); + void clearTree(); QString m_path; + // The rows as shown: the folder's own items (m_top) with each open + // folder's items below it. QVector m_entries; + QVector m_top; + // Folders open in the tree, by path. One inside a folder that's been + // closed stays here, so it comes back open with it, until the model + // moves on to another folder or a search. + QSet m_open; + // What each open folder holds, as last listed. + QHash> m_listed; + // The open folders being watched (those showing), the ones that changed + // and wait to be re-read, and each one's newest listing. + QSet m_childWatched; + QSet m_childDirty; + QHash m_childListing; + quint64 m_childSerial = 0; + // Bumped whenever the tree is cleared, so listings of a folder left + // behind stop early. + std::shared_ptr> m_treeLatest = std::make_shared>(1); QSet m_selected; void setSelected(const QSet &paths); bool m_loading = false; @@ -182,5 +240,6 @@ class FolderModel : public QAbstractListModel { std::shared_ptr> m_latest = std::make_shared>(0); QFileSystemWatcher m_watcher; QTimer m_debounce; + QTimer m_childDebounce; QTimer m_staleClear; }; diff --git a/src/i18n/en.json b/src/i18n/en.json index 369dbda5..dc5eb303 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -67,6 +67,7 @@ "Close": "Close", "Close the window": "Close the window", "Close this pane": "Close this pane", + "Collapse %1": "Collapse %1", "Compress": "Compress", "Compressing %L1 of %Ln items into %2…": { "one": "Compressing %L1 of %Ln item into %2…", @@ -169,6 +170,7 @@ "Emptying the trash…": "Emptying the trash…", "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.", + "Expand %1": "Expand %1", "Extract": "Extract", "Extracting %1: %Ln items…": { "one": "Extracting %1: %Ln item…", @@ -211,6 +213,7 @@ "Hide Hidden Files": "Hide Hidden Files", "Home": "Home", "In list and columns: into a folder / back out": "In list and columns: into a folder / back out", + "In the list: the tree, folders opening in place, on or off": "In the list: the tree, folders opening in place, on or off", "Keep Both": "Keep Both", "Keyboard shortcuts": "Keyboard shortcuts", "Kind": "Kind", @@ -394,6 +397,9 @@ "This folder is empty": "This folder is empty", "This folder no longer exists.": "This folder no longer exists.", "Trash": "Trash", + "Tree": "Tree", + "Tree off": "Tree off", + "Tree on": "Tree on", "Try again once the work under way is done.": "Try again once the work under way is done.", "Type a path, or a server's address like sftp://host/ (Tab completes)": "Type a path, or a server's address like sftp://host/ (Tab completes)", "Undid compressing into %1.": "Undid compressing into %1.", @@ -439,6 +445,7 @@ "Untitled Folder %1": "Untitled Folder %1", "User": "User", "Views": "Views", + "With the tree on: open a folder in place / close it or step out": "With the tree on: open a folder in place / close it or step out", "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/src/windows.cpp b/src/windows.cpp index d9a049c5..505fad00 100644 --- a/src/windows.cpp +++ b/src/windows.cpp @@ -7,9 +7,9 @@ #include "backend.h" #include "fileops.h" -Windows::Windows(QQmlEngine *engine, Plugins *plugins, QObject *parent) - : QObject(parent), m_engine(engine), m_plugins(plugins), m_component(engine, QUrl("qrc:/Main.qml")), - m_pane(engine, QUrl("qrc:/FolderPane.qml")) { +Windows::Windows(QQmlEngine *engine, Plugins *plugins, QObject *parent, const QUrl &interface) + : QObject(parent), m_engine(engine), m_plugins(plugins), m_component(engine, interface.resolved(QUrl("Main.qml"))), + m_pane(engine, interface.resolved(QUrl("FolderPane.qml"))) { // Main.qml calls windows.openSplit() and windows.closeSplit(). engine->rootContext()->setContextProperty("windows", this); } diff --git a/src/windows.h b/src/windows.h index d7d7801e..4bbbdd01 100644 --- a/src/windows.h +++ b/src/windows.h @@ -19,7 +19,10 @@ class Windows : public QObject { Q_OBJECT public: - explicit Windows(QQmlEngine *engine, Plugins *plugins, QObject *parent = nullptr); + // `interface` is where Main.qml and FolderPane.qml are: the app's + // resources, or for the tests the source folder. + explicit Windows(QQmlEngine *engine, Plugins *plugins, QObject *parent = nullptr, + const QUrl &interface = QUrl(QStringLiteral("qrc:/"))); // Opens a window on a folder, or on a file's folder with it selected. // Null if the interface failed to load (the errors are on stderr). diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 21e81d07..670a7f46 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -46,6 +46,7 @@ #include "theme.h" #include "thumbnails.h" #include "translator.h" +#include "windows.h" // Puts XDG_DATA_HOME, where the trash and recent files live, in `path` for // as long as the returned guard does. @@ -759,6 +760,660 @@ private slots: QCOMPARE(model.get(0).value("path").toString(), b.filePath("report.txt")); } + void treeOpensFoldersInPlace() + { + // In the list view an open folder's items are rows below it, one + // level further in, and work like any other row. + QTemporaryDir root; + treeFixture(root); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(root.path()); + QVERIFY(loaded.wait(3000)); + QCOMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + QCOMPARE(rowDepths(model), QList({0, 0, 0})); + QCOMPARE(openRows(model), QList()); + + QVERIFY(model.expand(0)); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Beta", "d.txt", "Zeta", "e.txt"})); + QCOMPARE(rowDepths(model), QList({0, 1, 1, 0, 0})); + QCOMPARE(openRows(model), QList({0})); + + QVERIFY(model.expand(1)); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Beta", "c.txt", "d.txt", "Zeta", "e.txt"})); + QCOMPARE(rowDepths(model), QList({0, 1, 2, 1, 0, 0})); + QCOMPARE(model.count(), 6); + QCOMPARE(model.get(2).value("path").toString(), root.filePath("Alpha/Beta/c.txt")); + QCOMPARE(model.indexOfPath(root.filePath("Alpha/Beta/c.txt")), 2); + + // A file has nothing to open. + QVERIFY(!model.expand(2)); + QCOMPARE(model.count(), 6); + + QCOMPARE(model.parentRow(2), 1); + QCOMPARE(model.parentRow(1), 0); + QCOMPARE(model.parentRow(0), -1); + QCOMPARE(model.parentRow(4), -1); + + // Closed and opened again, it shows at once, with Beta still open. + QVERIFY(model.collapse(0)); + QCOMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + QVERIFY(model.expand(0)); + QCOMPARE(rowNames(model), QStringList({"Alpha", "Beta", "c.txt", "d.txt", "Zeta", "e.txt"})); + } + + void treeKeepsOpenFoldersThroughChanges() + { + // Open folders are watched: what changes in them shows, and they + // stay open through a re-read until the model goes elsewhere. + QTemporaryDir root; + treeFixture(root); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(root.path()); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.expand(0)); + QTRY_COMPARE(rowNames(model).size(), 5); + QVERIFY(model.expand(1)); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Beta", "c.txt", "d.txt", "Zeta", "e.txt"})); + + write(root.filePath("Alpha/a2.txt"), "x"); + QTRY_COMPARE_WITH_TIMEOUT(rowNames(model), + QStringList({"Alpha", "Beta", "c.txt", "a2.txt", "d.txt", "Zeta", "e.txt"}), 5000); + QVERIFY(QDir(root.filePath("Alpha/Beta")).removeRecursively()); + QTRY_COMPARE_WITH_TIMEOUT(rowNames(model), QStringList({"Alpha", "a2.txt", "d.txt", "Zeta", "e.txt"}), 5000); + + model.reload(); + QVERIFY(loaded.wait(3000)); + QCOMPARE(rowNames(model), QStringList({"Alpha", "a2.txt", "d.txt", "Zeta", "e.txt"})); + QCOMPARE(openRows(model), QList({0})); + + model.setPath(root.filePath("Zeta")); + QVERIFY(loaded.wait(3000)); + model.setPath(root.path()); + QVERIFY(loaded.wait(3000)); + QCOMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + QCOMPARE(openRows(model), QList()); + } + + void treeLetsGoOfHiddenSelection() + { + // Closing a folder unselects what was in it, so a copy or a delete + // never takes along something out of sight. + QTemporaryDir root; + treeFixture(root); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(root.path()); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.expand(0)); + QTRY_COMPARE(rowNames(model).size(), 5); + QVERIFY(model.expand(1)); + QTRY_COMPARE(rowNames(model).size(), 6); + + model.selectOnly(model.indexOfPath(root.filePath("Alpha/Beta/c.txt"))); + model.toggleSelected(model.indexOfPath(root.filePath("e.txt"))); + QCOMPARE(model.selectedCount(), 2); + QVERIFY(model.collapse(0)); + QCOMPARE(model.selectedPaths(), QStringList({root.filePath("e.txt")})); + + QVERIFY(model.expand(0)); + QTRY_COMPARE(model.count(), 6); + model.selectAll(); + QCOMPARE(model.selectedCount(), model.count()); + QCOMPARE(model.selectedCount(), 6); + } + + void treeStaysFlatWhereItCannotNest() + { + // Search results come from all over, and Recents isn't a folder: + // neither opens folders in place. + QTemporaryDir root, data; + treeFixture(root); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(root.path()); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.expand(0)); + QTRY_COMPARE(rowNames(model).size(), 5); + + model.setQuery("txt"); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.count() > 0); + for (int row = 0; row < model.count(); ++row) + QVERIFY(!model.expand(row)); + QCOMPARE(rowDepths(model), QList(model.count(), 0)); + + // The folder comes back closed. + model.setQuery(""); + QVERIFY(loaded.wait(3000)); + QCOMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + QCOMPARE(openRows(model), QList()); + + const QByteArray url = QUrl::fromLocalFile(root.path()).toEncoded(); + write(data.filePath("recently-used.xbel"), + "\n\n" + "\n" + "\n" + "\n"); + const auto restore = dataHomeAt(data.path()); + model.setPath(FolderModel::recentPath()); + QVERIFY(loaded.wait(3000)); + QCOMPARE(model.count(), 2); + QCOMPARE(model.get(0).value("path").toString(), root.filePath("Alpha")); + QVERIFY(!model.expand(0)); + QCOMPARE(model.count(), 2); + } + + void treeListKeys() + { + // In the list → opens a folder in place and steps into it, ← closes + // it and steps back out, and at the top ← still goes up. ⏎ still + // goes into a folder. Mirrored, the arrows swap. + QTemporaryDir home, root; + treeFixture(root); + QDir(home.path()).mkpath("plugins"); + Plugins plugins("en", {home.filePath("plugins")}, home.filePath("folio.conf")); + Backend backend(home.path()); + backend.navigate(root.path()); + FolderModel &model = *backend.folder(); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + Theme theme(home.filePath("theme")); + FileOps fileOps; + QQmlEngine engine; + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("fileOps", &fileOps); + engine.rootContext()->setContextProperty("plugins", &plugins); + engine.rootContext()->setContextProperty("translator", QVariantMap{{"language", "en"}}); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window {\n" + " property bool mirrored\n" + " width: 800; height: 600; visible: true\n" + " Item { anchors.fill: parent\n" + " LayoutMirroring.enabled: mirrored; LayoutMirroring.childrenInherit: true\n" + // The tree is off by default: these keys are the tree's. + " FolderList { id: list; anchors.fill: parent; tree: true } }\n" + // ⏎ belongs to the pane: the same shortcut as there. + " Shortcut { sequence: 'Return'; onActivated: backend.open(backend.folder.get(list.currentIndex).path) }\n" + " readonly property int cursor: list.currentIndex\n" + " function place(row) { list.select(row, false); list.forceActiveFocus() }\n" + "}\n", + QUrl::fromLocalFile(sourceFolder() + "/FolderListTest.qml")); + std::unique_ptr created(component.create()); + QVERIFY2(created, qPrintable(component.errorString())); + auto *window = qobject_cast(created.get()); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window)); + const auto cursor = [&] { return window->property("cursor").toInt(); }; + const auto place = [&](int row) { QMetaObject::invokeMethod(window, "place", Q_ARG(QVariant, row)); }; + + place(0); + QCOMPARE(cursor(), 0); + QTest::keyClick(window, Qt::Key_Right); + QCOMPARE(openRows(model), QList({0})); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Beta", "d.txt", "Zeta", "e.txt"})); + QCOMPARE(cursor(), 0); + QTest::keyClick(window, Qt::Key_Right); + QCOMPARE(cursor(), 1); + QTest::keyClick(window, Qt::Key_Right); + QCOMPARE(openRows(model), QList({0, 1})); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Beta", "c.txt", "d.txt", "Zeta", "e.txt"})); + QCOMPARE(cursor(), 1); + QTest::keyClick(window, Qt::Key_Right); + QCOMPARE(cursor(), 2); + // A file has nowhere to go. + QTest::keyClick(window, Qt::Key_Right); + QCOMPARE(cursor(), 2); + QCOMPARE(model.count(), 6); + QCOMPARE(backend.path(), root.path()); + + QTest::keyClick(window, Qt::Key_Left); + QCOMPARE(cursor(), 1); + QTest::keyClick(window, Qt::Key_Left); + QCOMPARE(rowNames(model), QStringList({"Alpha", "Beta", "d.txt", "Zeta", "e.txt"})); + QCOMPARE(cursor(), 1); + QTest::keyClick(window, Qt::Key_Left); + QCOMPARE(cursor(), 0); + QTest::keyClick(window, Qt::Key_Left); + QCOMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + QCOMPARE(cursor(), 0); + // At the top, ← goes up as it always has. + QTest::keyClick(window, Qt::Key_Left); + QTRY_COMPARE(backend.path(), QFileInfo(root.path()).path()); + + backend.navigate(root.path()); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + place(1); + QTest::keyClick(window, Qt::Key_Return); + QTRY_COMPARE(backend.path(), root.filePath("Zeta")); + + backend.navigate(root.path()); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + window->setProperty("mirrored", true); + place(0); + QTest::keyClick(window, Qt::Key_Left); + QCOMPARE(openRows(model), QList({0})); + QTRY_COMPARE(rowNames(model).size(), 5); + QCOMPARE(backend.path(), root.path()); + QTest::keyClick(window, Qt::Key_Right); + QCOMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + QCOMPARE(cursor(), 0); + } + + void treeArrowKeepsTheCursorOnTheFolder() + { + // Closed by its arrow with the cursor on an item inside, the folder + // takes the cursor: on the next row instead, Delete would take a + // file that was never picked. + QTemporaryDir root; + treeFixture(root); + TreeList view(root.path()); + QVERIFY(view.shown()); + FolderModel &model = *view.backend.folder(); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + QVERIFY(model.expand(0)); + QTRY_COMPARE(rowNames(model).size(), 5); + QVERIFY(model.expand(1)); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Beta", "c.txt", "d.txt", "Zeta", "e.txt"})); + view.moveCursor(2); + QCOMPARE(model.selectedPaths(), QStringList({root.filePath("Alpha/Beta/c.txt")})); + + view.toggle(0); + QCOMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + QCOMPARE(view.cursor(), 0); + QCOMPARE(model.selectedPaths(), QStringList({root.filePath("Alpha")})); + QTest::qWait(100); + QCOMPARE(view.cursor(), 0); + QCOMPARE(model.selectedPaths(), QStringList({root.filePath("Alpha")})); + } + + void treeKeepsTheSelectionAsRowsArrive() + { + // Rows showing above the cursor (a folder opened, a file appearing) + // move it down with its item and leave a selection of several alone. + QTemporaryDir root; + treeFixture(root); + TreeList view(root.path()); + QVERIFY(view.shown()); + FolderModel &model = *view.backend.folder(); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + view.moveCursor(2); + model.toggleSelected(1); + const QStringList picked{root.filePath("Zeta"), root.filePath("e.txt")}; + QCOMPARE(model.selectedPaths(), picked); + + view.toggle(0); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Beta", "d.txt", "Zeta", "e.txt"})); + QTest::qWait(100); + QCOMPARE(model.selectedPaths(), picked); + QCOMPARE(view.cursor(), 4); + QCOMPARE(model.get(view.cursor()).value("path").toString(), root.filePath("e.txt")); + + write(root.filePath("b.txt"), "x"); + QTRY_COMPARE_WITH_TIMEOUT(rowNames(model), + QStringList({"Alpha", "Beta", "d.txt", "Zeta", "b.txt", "e.txt"}), 5000); + QTest::qWait(100); + QCOMPARE(model.selectedPaths(), picked); + QCOMPARE(model.get(view.cursor()).value("path").toString(), root.filePath("e.txt")); + } + + void treeFindsNamesInTheFolderShown() + { + // What's pasted, made or renamed is in the folder shown, so finding + // it by name passes over the same name in an open folder. + QTemporaryDir root; + treeFixture(root); + write(root.filePath("d.txt"), "x"); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(root.path()); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.expand(0)); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Beta", "d.txt", "Zeta", "d.txt", "e.txt"})); + QCOMPARE(model.get(2).value("path").toString(), root.filePath("Alpha/d.txt")); + QCOMPARE(model.indexOf("d.txt"), 4); + QCOMPARE(model.get(model.indexOf("d.txt")).value("path").toString(), root.filePath("d.txt")); + } + + void treeKeepsTheCursorThroughANewOrder() + { + // Sorted another way, an open folder's items come in their new + // order after the folder itself: the cursor stays on its item, and + // nothing else gets selected. + QTemporaryDir root; + treeFixture(root); + write(root.filePath("Alpha/a2.txt"), "x"); + const QDateTime now = QDateTime::currentDateTime(); + for (const auto &[name, when] : {qMakePair(QString("Alpha/a2.txt"), now), + qMakePair(QString("Alpha/d.txt"), now.addSecs(-120))}) { + QFile file(root.filePath(name)); + QVERIFY(file.open(QIODevice::ReadWrite)); + QVERIFY(file.setFileTime(when, QFileDevice::FileModificationTime)); + } + TreeList view(root.path()); + QVERIFY(view.shown()); + FolderModel &model = *view.backend.folder(); + QTRY_COMPARE(rowNames(model).size(), 3); + const auto inAlpha = [&] { + QStringList names; + const int alpha = model.indexOfPath(root.filePath("Alpha")); + for (int row = 0; row < model.count(); ++row) + if (alpha >= 0 && model.parentRow(row) == alpha) + names << model.get(row).value("name").toString(); + return names; + }; + model.setSortMode("modified"); + QTRY_VERIFY(!model.loading()); + QVERIFY(model.expand(model.indexOfPath(root.filePath("Alpha")))); + QTRY_COMPARE(inAlpha(), QStringList({"Beta", "a2.txt", "d.txt"})); + QTest::qWait(100); + view.moveCursor(model.indexOfPath(root.filePath("Alpha/d.txt"))); + QCOMPARE(model.selectedPaths(), QStringList({root.filePath("Alpha/d.txt")})); + + model.setSortMode("oldest"); + QTRY_COMPARE_WITH_TIMEOUT(inAlpha(), QStringList({"Beta", "d.txt", "a2.txt"}), 5000); + QTest::qWait(100); + QCOMPARE(model.get(view.cursor()).value("path").toString(), root.filePath("Alpha/d.txt")); + QCOMPARE(model.selectedPaths(), QStringList({root.filePath("Alpha/d.txt")})); + } + + void treeOpensALargeFolderWithoutFreezing() + { + // 20,000 items opened above 20,000 more go in as one stretch, not + // one row at a time with everything below moving each time. + QTemporaryDir root; + QDir(root.path()).mkpath("A"); + for (int i = 0; i < 20000; ++i) { + QVERIFY(QFile(root.filePath(QString("A/a%1").arg(i, 5, 10, QChar('0')))).open(QIODevice::WriteOnly)); + QVERIFY(QFile(root.filePath(QString("f%1").arg(i, 5, 10, QChar('0')))).open(QIODevice::WriteOnly)); + } + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(root.path()); + QVERIFY(loaded.wait(10000)); + QCOMPARE(model.count(), 20001); + QCOMPARE(model.get(0).value("name").toString(), QString("A")); + + QElapsedTimer clock; + qint64 last = 0, longest = 0; + QTimer ticks; + ticks.setInterval(10); + connect(&ticks, &QTimer::timeout, [&] { + longest = qMax(longest, clock.elapsed() - last); + last = clock.elapsed(); + }); + clock.start(); + ticks.start(); + QVERIFY(model.expand(0)); + QTRY_COMPARE_WITH_TIMEOUT(model.count(), 40001, 15000); + ticks.stop(); + longest = qMax(longest, clock.elapsed() - last); + QVERIFY2(longest < 250, qPrintable(QString("the window was held up for %1 ms").arg(longest))); + } + + void treeIsOffUntilTurnedOn() + { + // Until the tree is turned on the list is the plain list it always + // was: no arrows, no lines, no indent, and → goes into a folder. + // Turned on, → opens it in place; turned off, what's open closes. + QTemporaryDir root; + treeFixture(root); + TreeList view(root.path(), false); + QVERIFY(view.shown()); + auto *window = qobject_cast(view.window.get()); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window)); + FolderModel &model = *view.backend.folder(); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + QCOMPARE(window->property("treeView").toBool(), false); + for (int row = 0; row < 3; ++row) { + QTRY_VERIFY(view.rowItem(row)); + QQuickItem *item = view.rowItem(row); + QCOMPARE(shownNamed(item, "disclosure"), 0); + QCOMPARE(shownNamed(item, "treeLines"), 0); + // Main's row: the name after the 14 px margin, the 20 px icon + // and the 12 px between them. + auto *name = item->findChild("rowName"); + QVERIFY(name); + QTRY_COMPARE(name->mapToItem(item, QPointF(0, 0)).x(), 46.0); + } + + view.place(0); + QCOMPARE(view.cursor(), 0); + QTest::keyClick(window, Qt::Key_Right); + QTRY_COMPARE(view.backend.path(), root.filePath("Alpha")); + QCOMPARE(openRows(model), QList()); + + view.backend.navigate(root.path()); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + window->setProperty("treeView", true); + view.place(0); + QTest::keyClick(window, Qt::Key_Right); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Beta", "d.txt", "Zeta", "e.txt"})); + QCOMPARE(view.backend.path(), root.path()); + QTest::keyClick(window, Qt::Key_Right); + QCOMPARE(view.cursor(), 1); + QTest::keyClick(window, Qt::Key_Right); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Beta", "c.txt", "d.txt", "Zeta", "e.txt"})); + + window->setProperty("treeView", false); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + QTRY_COMPARE(view.cursor(), 0); + QCOMPARE(model.get(view.cursor()).value("path").toString(), root.filePath("Alpha")); + QCOMPARE(view.backend.path(), root.path()); + } + + void treeDrawsItsLines() + { + // Rows in open folders are joined by lines, as the tree command + // draws them: a line carrying on for each outer folder with more + // rows to come, then a tee, or an elbow for a folder's last item. + QTemporaryDir root; + treeFixture(root); + FolderModel model; + QSignalSpy loaded(&model, &FolderModel::loaded); + model.setPath(root.path()); + QVERIFY(loaded.wait(3000)); + QVERIFY(model.expand(0)); + QTRY_COMPARE(rowNames(model).size(), 5); + QVERIFY(model.expand(1)); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Beta", "c.txt", "d.txt", "Zeta", "e.txt"})); + QCOMPARE(rowLines(model), QStringList({"", "├", "│└", "└", "", ""})); + + write(root.filePath("Alpha/Beta/f.txt"), "x"); + QTRY_COMPARE_WITH_TIMEOUT(rowNames(model), + QStringList({"Alpha", "Beta", "c.txt", "f.txt", "d.txt", "Zeta", "e.txt"}), 5000); + QCOMPARE(rowLines(model), QStringList({"", "├", "│├", "│└", "└", "", ""})); + + QVERIFY(model.collapse(1)); + QCOMPARE(rowNames(model), QStringList({"Alpha", "Beta", "d.txt", "Zeta", "e.txt"})); + QCOMPARE(rowLines(model), QStringList({"", "├", "└", "", ""})); + + // Drawn in the list with the tree on, and nowhere else. + TreeList view(root.path()); + QVERIFY(view.shown()); + FolderModel &shown = *view.backend.folder(); + QTRY_COMPARE(rowNames(shown).size(), 3); + QVERIFY(shown.expand(0)); + QTRY_COMPARE(rowNames(shown).size(), 5); + QVERIFY(shown.expand(1)); + QTRY_COMPARE(rowNames(shown), QStringList({"Alpha", "Beta", "c.txt", "f.txt", "d.txt", "Zeta", "e.txt"})); + QTRY_VERIFY(view.rowItem(2)); + QTRY_COMPARE(shownNamed(view.rowItem(2), "treeLines"), 1); + + view.window->setProperty("treeView", false); + QTRY_COMPARE(rowNames(shown), QStringList({"Alpha", "Zeta", "e.txt"})); + auto *list = qobject_cast(view.window.get()); + QTRY_COMPARE(shownNamed(list->contentItem(), "rowName"), 3); + QCOMPARE(shownNamed(list->contentItem(), "treeLines"), 0); + + // In the columns, even with folders open in the model below them. + qmlRegisterType("Folio", 1, 0, "FolderModel"); + QQmlComponent component(&view.engine); + component.setData("import QtQuick\n" + "Window {\n" + " width: 1200; height: 600; visible: true\n" + " FolderColumns { anchors.fill: parent }\n" + "}\n", + QUrl::fromLocalFile(sourceFolder() + "/FolderColumnsTest.qml")); + std::unique_ptr created(component.create()); + QVERIFY2(created, qPrintable(component.errorString())); + auto *columns = qobject_cast(created.get()); + QVERIFY(QTest::qWaitForWindowExposed(columns)); + QVERIFY(shown.expand(0)); + QTRY_COMPARE(rowNames(shown).size(), 5); + QVERIFY(shown.expand(1)); + QTRY_COMPARE(rowNames(shown).size(), 7); + QTRY_VERIFY(shownNamed(columns->contentItem(), "rowName") >= 7); + QCOMPARE(shownNamed(columns->contentItem(), "treeLines"), 0); + } + + void treeKeyAndButton() + { + // In the list, T and the tree button beside the view button turn the + // tree on and off; the key says so. The grid and the columns have + // no tree: no button, and T does nothing. + QTemporaryDir home, folder; + treeFixture(folder); + QDir(home.path()).mkpath("plugins"); + Places places(home.path()); + Plugins plugins("en", {home.filePath("plugins")}, home.filePath("folio.conf")); + Backend backend(home.path()); + backend.navigate(folder.path()); + Theme theme(home.filePath("theme")); + FileOps fileOps; + QQmlEngine engine; + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("fileOps", &fileOps); + engine.rootContext()->setContextProperty("places", &places); + engine.rootContext()->setContextProperty("plugins", &plugins); + engine.rootContext()->setContextProperty("translator", QVariantMap{{"language", "en"}}); + std::unique_ptr window = paneHarness(engine); + QVERIFY(window); + window->requestActivate(); + QVERIFY(QTest::qWaitForWindowFocused(window.get())); + QQuickItem *pane = window->property("activePane").value(); + QVERIFY(pane); + const QList buttons = itemsNamed(window->contentItem(), "treeButton"); + QCOMPARE(buttons.size(), 1); + QQuickItem *button = buttons.first(); + QCOMPARE(button->property("text").toString(), QString("Tree")); + const auto focusFiles = [&] { + QMetaObject::invokeMethod(pane, "focusView"); + return QTest::qWaitFor([&] { return pane->hasActiveFocus() && !button->hasActiveFocus(); }, 2000); + }; + + window->setProperty("viewMode", "list"); + QTRY_VERIFY(folderListIn(pane)); + QVERIFY(focusFiles()); + QTRY_VERIFY(button->isVisible()); + QCOMPARE(button->property("checked").toBool(), false); + QCOMPARE(window->property("treeView").toBool(), false); + QTest::keyClick(window.get(), Qt::Key_T); + QCOMPARE(window->property("treeView").toBool(), true); + QCOMPARE(button->property("checked").toBool(), true); + QCOMPARE(backend.notice(), QString("Tree on")); + QTest::mouseClick(window.get(), Qt::LeftButton, {}, + button->mapToScene(QPointF(button->width() / 2, button->height() / 2)).toPoint()); + QTRY_COMPARE(window->property("treeView").toBool(), false); + QCOMPARE(button->property("checked").toBool(), false); + + for (const char *mode : {"grid", "columns"}) { + window->setProperty("viewMode", mode); + QTRY_VERIFY(!button->isVisible()); + QVERIFY(focusFiles()); + QTest::keyClick(window.get(), Qt::Key_T); + QTest::qWait(50); + QVERIFY2(!window->property("treeView").toBool(), mode); + } + } + + void viewButtonCyclesViews() + { + // The view button moves to the next view at each click, grid, list, + // columns and round again, and opens nothing. + QTemporaryDir home, folder; + treeFixture(folder); + QDir(home.path()).mkpath("plugins"); + Places places(home.path()); + Plugins plugins("en", {home.filePath("plugins")}, home.filePath("folio.conf")); + Backend backend(home.path()); + backend.navigate(folder.path()); + Theme theme(home.filePath("theme")); + FileOps fileOps; + QQmlEngine engine; + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("fileOps", &fileOps); + engine.rootContext()->setContextProperty("places", &places); + engine.rootContext()->setContextProperty("plugins", &plugins); + engine.rootContext()->setContextProperty("translator", QVariantMap{{"language", "en"}}); + std::unique_ptr window = paneHarness(engine); + QVERIFY(window); + const QList buttons = itemsNamed(window->contentItem(), "viewButton"); + QCOMPARE(buttons.size(), 1); + QQuickItem *button = buttons.first(); + // A menu or any other popup open in the window; tooltips aside, + // which come from hovering, not from the click. + const auto popupShown = [&] { + for (QObject *each : window->findChildren()) + if (each->inherits("QQuickPopup") && !each->inherits("QQuickToolTip") && each->property("visible").toBool()) + return true; + return false; + }; + window->setProperty("viewMode", "grid"); + for (const char *next : {"list", "columns", "grid"}) { + QTest::mouseClick(window.get(), Qt::LeftButton, {}, + button->mapToScene(QPointF(button->width() / 2, button->height() / 2)).toPoint()); + QTRY_COMPARE(window->property("viewMode").toString(), QString(next)); + QTest::qWait(100); + QVERIFY2(!popupShown(), next); + } + } + + void treeLinesTurnRoundRightToLeft() + { + // Mirrored, the tree's lines lead from the arrows at the row's + // right end. + QTemporaryDir root; + treeFixture(root); + QGuiApplication::setLayoutDirection(Qt::RightToLeft); + const auto leftToRight = qScopeGuard([] { QGuiApplication::setLayoutDirection(Qt::LeftToRight); }); + WholeWindow main(root.path()); + QVERIFY(main.shown()); + + // c.txt, in Beta in Alpha: its first line runs down from Alpha's + // arrow, 14 px in from the row's end and 7 into the first 16 px step. + main.window->setProperty("viewMode", "list"); + main.window->setProperty("treeView", true); + FolderModel &model = *main.backend->folder(); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Zeta", "e.txt"})); + QVERIFY(model.expand(0)); + QTRY_COMPARE(rowNames(model).size(), 5); + QVERIFY(model.expand(1)); + QTRY_COMPARE(rowNames(model), QStringList({"Alpha", "Beta", "c.txt", "d.txt", "Zeta", "e.txt"})); + QQuickItem *list = nullptr; + QTRY_VERIFY((list = folderListIn(main.pane("activePane")))); + // Mapped to the row, from x 0 at its left; -1 until it's laid out. + const auto firstGuide = [&]() -> QRectF { + QQuickItem *row = listRow(list, 2); + for (QQuickItem *guide : row ? itemsNamed(row, "treeGuide") : QList()) + if (guide->isVisible() && guide->parentItem()->property("index").toInt() == 0) + return guide->mapRectToItem(row, QRectF(0, 0, guide->width(), guide->height())); + return QRectF(-1, 0, 0, 0); + }; + QTRY_VERIFY(listRow(list, 2)); + const qreal rowWidth = listRow(list, 2)->width(); + QTRY_COMPARE(firstGuide().x(), rowWidth - 22); + QCOMPARE(firstGuide().width(), 1.0); + QGuiApplication::setLayoutDirection(Qt::LeftToRight); + QTRY_COMPARE(firstGuide().x(), 21.0); + QCOMPARE(firstGuide().width(), 1.0); + } + void fileOpsDeleteForeverOutsideTheTrash() { // Shift+Delete deletes for good anywhere: the folder and what's in @@ -6080,6 +6735,252 @@ private slots: return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); } + // A list view on a real backend showing `folder`, for the tree tests + // that need the view's cursor. moveCursor() moves it as the arrows do + // (selecting what it lands on), toggle() clicks a row's arrow. + // `tree` turns the tree on, as the tree tests need; left false, the + // list keeps its own default. + struct TreeList { + explicit TreeList(const QString &folder, bool tree = true) + : plugins("en", {pluginsIn(home)}, home.filePath("folio.conf")), backend(home.path()), + theme(home.filePath("theme")) + { + backend.navigate(folder); + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("fileOps", &fileOps); + engine.rootContext()->setContextProperty("plugins", &plugins); + engine.rootContext()->setContextProperty("translator", QVariantMap{{"language", "en"}}); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window {\n" + " width: 800; height: 600; visible: true\n" + + QByteArray(tree ? " FolderList { id: list; anchors.fill: parent; tree: true }\n" + : " FolderList { id: list; anchors.fill: parent }\n") + + " property alias treeView: list.tree\n" + " readonly property int cursor: list.currentIndex\n" + " function moveCursor(row) { list.currentIndex = row }\n" + " function toggle(row) { list.itemAtIndex(row).toggled() }\n" + " function place(row) { list.select(row, false); list.forceActiveFocus() }\n" + " function rowItem(row) { return list.itemAtIndex(row) }\n" + "}\n", + QUrl::fromLocalFile(sourceFolder() + "/FolderListTest.qml")); + window.reset(component.create()); + if (!window) + qWarning() << component.errorString(); + } + static QString pluginsIn(const QTemporaryDir &home) + { + QDir(home.path()).mkpath("plugins"); + return home.filePath("plugins"); + } + bool shown() const { return window && QTest::qWaitForWindowExposed(qobject_cast(window.get())); } + int cursor() const { return window->property("cursor").toInt(); } + void moveCursor(int row) { QMetaObject::invokeMethod(window.get(), "moveCursor", Q_ARG(QVariant, row)); } + void toggle(int row) { QMetaObject::invokeMethod(window.get(), "toggle", Q_ARG(QVariant, row)); } + void place(int row) { QMetaObject::invokeMethod(window.get(), "place", Q_ARG(QVariant, row)); } + QQuickItem *rowItem(int row) const + { + QVariant item; + QMetaObject::invokeMethod(window.get(), "rowItem", Q_RETURN_ARG(QVariant, item), Q_ARG(QVariant, row)); + return item.value(); + } + + QTemporaryDir home; + Plugins plugins; + Backend backend; + Theme theme; + FileOps fileOps; + QQmlEngine engine; + std::unique_ptr window; + }; + + // Folio's own window: Main.qml, opened as the app opens it through + // Windows, but read from the source folder. Main.qml's Settings go to a + // folder of the tests' own, emptied for each window, so every window + // starts from the defaults. + struct WholeWindow { + explicit WholeWindow(const QString &folder) + : places(home.path()), plugins("en", {TreeList::pluginsIn(home)}, home.filePath("folio.conf")), + theme(home.filePath("theme")) + { + static QTemporaryDir kept; + QSettings::setPath(QSettings::NativeFormat, QSettings::UserScope, kept.path()); + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, kept.path()); + QFile::remove(QSettings().fileName()); + qmlRegisterType("Folio", 1, 0, "FolderModel"); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("places", &places); + engine.rootContext()->setContextProperty("plugins", &plugins); + engine.rootContext()->setContextProperty("translator", QVariantMap{{"language", "en"}}); + windows = std::make_unique(&engine, &plugins, nullptr, QUrl::fromLocalFile(sourceFolder() + "/")); + const QWindowList before = QGuiApplication::topLevelWindows(); + backend = windows->open(folder); + for (QWindow *each : QGuiApplication::topLevelWindows()) + if (!before.contains(each) && each->property("treeView").isValid()) + window = qobject_cast(each); + } + ~WholeWindow() + { + // Closing it releases its panes, as the app does. + if (window) + window->close(); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + // On screen and taking clicks and keys: Main.qml waits for its size. + bool shown() const + { + return window && QTest::qWaitForWindowExposed(window) + && QTest::qWaitFor([this] { return window->property("settled").toBool(); }, 3000); + } + QQuickItem *pane(const char *name) const { return window->property(name).value(); } + + QTemporaryDir home; + Places places; + Plugins plugins; + Theme theme; + QQmlEngine engine; + std::unique_ptr windows; + Backend *backend = nullptr; + QQuickWindow *window = nullptr; + }; + + // A pane in a window with Main.qml's side of it (the view and the + // tree), for the path bar's buttons and the pane's keys. `engine` has + // the contexts. + static std::unique_ptr paneHarness(QQmlEngine &engine) + { + qmlRegisterType("Folio", 1, 0, "FolderModel"); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "import QtQuick.Controls\n" + "import Folio\n" + "ApplicationWindow {\n" + " id: win\n" + " width: 1000; height: 600; visible: true\n" + " property string viewMode: 'columns'\n" + " property real zoom: 1\n" + " property bool treeView: false\n" + " property bool previewVisible: false\n" + " property bool detailsFit: true\n" + " property bool split: false\n" + " property bool helpVisible: false\n" + " property var activePane: pane\n" + " FolderPane { id: pane; anchors.fill: parent; focus: true }\n" + "}\n", + QUrl::fromLocalFile(sourceFolder() + "/PaneTest.qml")); + std::unique_ptr window(qobject_cast(component.create())); + if (!window) { + qWarning() << component.errorString(); + return {}; + } + if (!QTest::qWaitForWindowExposed(window.get())) + return {}; + return window; + } + + // The list view in a pane, once it has loaded. + static QQuickItem *folderListIn(QQuickItem *top) + { + if (!top) + return nullptr; + for (QQuickItem *child : top->childItems()) { + if (QString(child->metaObject()->className()).startsWith("FolderList")) + return child; + if (QQuickItem *found = folderListIn(child)) + return found; + } + return nullptr; + } + + // A list's row item, while it's laid out. + static QQuickItem *listRow(QQuickItem *list, int row) + { + QQuickItem *item = nullptr; + QMetaObject::invokeMethod(list, "itemAtIndex", Q_RETURN_ARG(QQuickItem *, item), Q_ARG(int, row)); + return item; + } + + // The first showing item with this objectName, optionally one whose + // middle is between `from` and `to` across the window. + static QQuickItem *shownItem(QQuickWindow *window, const QString &name, qreal from = -1, qreal to = 1e9) + { + for (QQuickItem *item : itemsNamed(window->contentItem(), name)) { + const qreal x = item->mapToScene(QPointF(item->width() / 2, 0)).x(); + if (item->isVisible() && x >= from && x <= to) + return item; + } + return nullptr; + } + + // The tree tests' folder: Alpha/Beta/c.txt, Alpha/d.txt, Zeta/z.txt + // and e.txt. + static void treeFixture(const QTemporaryDir &root) + { + QDir(root.path()).mkpath("Alpha/Beta"); + QDir(root.path()).mkpath("Zeta"); + write(root.filePath("Alpha/Beta/c.txt"), "x"); + write(root.filePath("Alpha/d.txt"), "x"); + write(root.filePath("Zeta/z.txt"), "x"); + write(root.filePath("e.txt"), "x"); + } + + // The rows' names, their depths and the open ones, as the views read them. + static QStringList rowNames(const FolderModel &model) + { + QStringList names; + for (int row = 0; row < model.count(); ++row) + names << model.data(model.index(row), FolderModel::NameRole).toString(); + return names; + } + + static QList rowDepths(const FolderModel &model) + { + QList depths; + for (int row = 0; row < model.count(); ++row) + depths << model.data(model.index(row), FolderModel::DepthRole).toInt(); + return depths; + } + + static QStringList rowLines(const FolderModel &model) + { + QStringList lines; + for (int row = 0; row < model.count(); ++row) + lines << model.data(model.index(row), FolderModel::LinesRole).toString(); + return lines; + } + + // The items under `top` with this objectName, through the items' own + // tree (a window's items aren't all its QObject children). + static QList itemsNamed(QQuickItem *top, const QString &name) + { + QList found; + for (QQuickItem *child : top->childItems()) { + if (child->objectName() == name) + found << child; + found << itemsNamed(child, name); + } + return found; + } + + // The items under `top` with this objectName that are showing. + static int shownNamed(QQuickItem *top, const QString &name) + { + int shown = 0; + for (QQuickItem *child : top->childItems()) + shown += (child->objectName() == name && child->isVisible()) + shownNamed(child, name); + return shown; + } + + static QList openRows(const FolderModel &model) + { + QList rows; + for (int row = 0; row < model.count(); ++row) + if (model.data(model.index(row), FolderModel::OpenRole).toBool()) + rows << row; + return rows; + } + static void write(const QString &path, const QByteArray &contents) { QFile file(path);