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);