From f52d2b20b4b0b24595c4edcb49bf42c73fc3e08f Mon Sep 17 00:00:00 2001 From: Kirill Kostin <15322854+k-kostin@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:10:36 +0300 Subject: [PATCH 1/2] Load ancestor columns near the viewport --- src/FolderColumns.qml | 130 +++++++++++++----- tests/folio_tests.cpp | 313 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 405 insertions(+), 38 deletions(-) diff --git a/src/FolderColumns.qml b/src/FolderColumns.qml index 7e802fff..07ab4e7f 100644 --- a/src/FolderColumns.qml +++ b/src/FolderColumns.qml @@ -43,8 +43,8 @@ Item { trail.setProperty(i, "next", want[i].next) if (trail.count > same) trail.remove(same, trail.count - same) - for (let i = same; i < want.length; ++i) - trail.append(want[i]) + if (same < want.length) + trail.append(want.slice(same)) } onCrumbsChanged: syncTrail() Component.onCompleted: syncTrail() @@ -89,49 +89,103 @@ Item { model: trail Row { id: ancestor + required property int index required property string path required property string next + width: view.columnWidth + 1 height: row.height + property real scrollY: NaN + onNextChanged: { + scrollY = NaN + if (columnLoader && columnLoader.item) columnLoader.item.revealNext() + } + Connections { + target: backend.folder + function onShowHiddenChanged() { ancestor.scrollY = NaN } + function onSortModeChanged() { ancestor.scrollY = NaN } + } + Connections { + target: translator + function onChanged() { ancestor.scrollY = NaN } + } - ListView { - id: column + Loader { + id: columnLoader + property Item dragSource: null width: view.columnWidth height: parent.height - clip: true - acceptedButtons: Qt.NoButton - boundsBehavior: Flickable.StopAtBounds - ScrollBar.vertical: FolioScrollBar { } - model: FolderModel { - id: folder - path: ancestor.path - showHidden: backend.folder.showHidden - sortMode: backend.folder.sortMode - onLoaded: column.revealNext() - } - // Names sort by the language's alphabet. - Connections { - target: translator - function onChanged() { folder.reload() } - } - function revealNext() { positionViewAtIndex(Math.max(0, folder.indexOf(ancestor.next)), ListView.Contain) } - ColumnDrop { items: column; folder: ancestor.path } - TapHandler { - acceptedButtons: Qt.RightButton - onTapped: (point) => { - const index = column.indexAt(point.position.x + column.contentX, point.position.y + column.contentY) - view.contextMenu(index >= 0 ? folder.get(index) : null, ancestor.path, point.scenePosition) + // Keep one column ready on either side. Its place in + // the row stays even when its model and rows are gone. + // Use the index, not Row's not-yet-laid-out x on creation. + readonly property real columnX: row.x + (view.LayoutMirroring.enabled + ? (trail.count - ancestor.index) * ancestor.width + (inside.visible ? inside.width : 0) + : ancestor.index * ancestor.width) + active: dragSource !== null || (columnX + width > flick.contentX - view.columnWidth + && columnX < flick.contentX + flick.width + view.columnWidth) + sourceComponent: ListView { + id: column + width: view.columnWidth + height: parent.height + clip: true + acceptedButtons: Qt.NoButton + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: FolioScrollBar { } + model: FolderModel { + id: folder + path: ancestor.path + showHidden: backend.folder.showHidden + sortMode: backend.folder.sortMode + onLoaded: if (column.complete) column.revealNext() + } + // Names sort by the language's alphabet. + Connections { + target: translator + function onChanged() { folder.reload() } + } + property bool complete: false + property bool positioned: false + property bool restoring: false + Connections { + target: folder + function onModelAboutToBeReset() { column.positioned = false } + } + function revealNext() { + const saved = ancestor.scrollY + restoring = true + forceLayout() + if (Number.isFinite(saved)) + contentY = originY + Math.max(0, Math.min(saved, contentHeight - height)) + else + positionViewAtIndex(Math.max(0, folder.indexOf(ancestor.next)), ListView.Contain) + restoring = false + positioned = true + if (Number.isFinite(saved)) ancestor.scrollY = contentY - originY + } + Component.onCompleted: { + complete = true + if (folder.count > 0 || !folder.loading) revealNext() + } + onContentYChanged: if (complete && positioned && !restoring && columnLoader.active) ancestor.scrollY = contentY - originY + ColumnDrop { items: column; folder: ancestor.path } + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: (point) => { + const index = column.indexAt(point.position.x + column.contentX, point.position.y + column.contentY) + view.contextMenu(index >= 0 ? folder.get(index) : null, ancestor.path, point.scenePosition) + } + } + delegate: FileRow { + id: entry + width: column.width + chevron: true + trail: name === ancestor.next + Drag.onActiveChanged: { + if (Drag.active) columnLoader.dragSource = entry + else if (columnLoader.dragSource === entry) columnLoader.dragSource = null + } + onClicked: isDir ? backend.navigate(path) : backend.showItem(path) + onActivated: backend.open(path) } - } - Connections { - target: ancestor - function onNextChanged() { column.revealNext() } - } - delegate: FileRow { - width: column.width - chevron: true - trail: name === ancestor.next - onClicked: isDir ? backend.navigate(path) : backend.showItem(path) - onActivated: backend.open(path) } } Separator { } diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 21e81d07..fb5e0379 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -19,10 +19,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -494,6 +496,7 @@ private slots: { // No test ever reaches the real udisks, which would change real disks. fakeDisks({}); + qmlRegisterType("Folio", 1, 0, "FolderModel"); } void folderListsDirectoriesFirstWithoutHidden() @@ -5739,6 +5742,316 @@ private slots: QVERIFY(!FolderModel::toMap(FolderModel::Entry()).contains("peek")); } + void columnsOnlyReadFoldersNearTheViewport_data() + { + QTest::addColumn("mirrored"); + QTest::addColumn("width"); + QTest::newRow("left-to-right") << false << 783; + QTest::newRow("right-to-left") << true << 783; + QTest::newRow("narrow") << false << 180; + QTest::newRow("narrow-mirrored") << true << 180; + } + + void columnsOnlyReadFoldersNearTheViewport() + { + QFETCH(bool, mirrored); + QFETCH(int, width); + const QLocale oldLocale; + const auto restoreLocale = qScopeGuard([oldLocale] { QLocale::setDefault(oldLocale); }); + QTemporaryDir home; + QString path = home.path(); + for (int i = 0; i < 24; ++i) { + write(path + "/note.txt", "x"); + path += "/step"; + QVERIFY(QDir().mkpath(path)); + } + for (int i = 0; i < 128; ++i) + write(home.filePath(QString("row%1.txt").arg(i, 3, 10, QChar('0'))), "x"); + QVERIFY(QDir().mkdir(path + "/peek")); + write(path + "/peek/inside.txt", "x"); + write(path + "/last.txt", "x"); + Backend backend(home.path()); + backend.navigate(path); + QTRY_VERIFY(!backend.folder()->loading()); + Theme theme(home.filePath("theme")); + FileOps fileOps; + write(home.filePath("language"), "en"); + Translator translator(home.filePath("language"), sourceFolder() + "/i18n"); + QQmlEngine engine; + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("fileOps", &fileOps); + engine.rootContext()->setContextProperty("translator", &translator); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window { property bool mirrored; width: 783; height: 480; visible: true\n" + " Item { anchors.fill: parent\n" + " LayoutMirroring.enabled: mirrored; LayoutMirroring.childrenInherit: true\n" + " FolderColumns { objectName: 'columns'; anchors.fill: parent } }\n" + "}\n", QUrl::fromLocalFile(sourceFolder() + "/ColumnsTest.qml")); + std::unique_ptr object(component.createWithInitialProperties({{"mirrored", mirrored}, {"width", width}})); + QVERIFY2(object, qPrintable(component.errorString())); + auto *window = qobject_cast(object.get()); + auto *columns = object->findChild("columns"); + QVERIFY(columns); + const auto items = [&] { + QList all; + std::function visit = [&](QQuickItem *item) { + all << item; + for (auto *child : item->childItems()) visit(child); + }; + visit(window->contentItem()); + return all; + }; + const auto modelFor = [](QQuickItem *item) { + return qobject_cast(item->property("model").value()); + }; + const auto models = [&] { + QList folders; + for (auto *item : items()) + if (auto *folder = modelFor(item); folder && !folder->path().isEmpty() && !folders.contains(folder)) + folders << folder; + return folders; + }; + const auto loaded = [&] { + const auto active = models(); + return !active.isEmpty() && std::all_of(active.cbegin(), active.cend(), [](auto *folder) { return !folder->loading(); }); + }; + const auto listFor = [&](const QString &path) -> QQuickItem * { + for (auto *item : items()) + if (auto *folder = modelFor(item); folder && folder->path() == path) return item; + return nullptr; + }; + // A long trail must not list all ancestors just to show three columns. + QTRY_VERIFY(loaded()); + QVERIFY2(models().size() <= 5, qPrintable(QString::number(models().size()))); + QQuickItem *flick = nullptr; + QTRY_VERIFY(([&] { + for (auto *item : items()) + if (!modelFor(item) && item->property("contentWidth").toDouble() > width) flick = item; + return flick != nullptr; + })()); + const auto scrollTo = [&](bool oldest) { + flick->setProperty("contentX", oldest != mirrored ? 0 : flick->property("contentWidth").toDouble() - flick->width()); + }; + const auto visibleAncestorsLoaded = [&] { + // Use actual Row geometry, independently of Loader's index formula. + const QPointF viewport = flick->mapToItem(window->contentItem(), QPointF()); + for (auto *ancestor : items()) { + if (!ancestor->property("next").isValid() || !ancestor->property("path").isValid()) continue; + const QPointF at = ancestor->mapToItem(window->contentItem(), QPointF()); + if (at.x() + ancestor->width() > viewport.x() && at.x() < viewport.x() + flick->width()) { + auto *list = listFor(ancestor->property("path").toString()); + if (!list || modelFor(list)->loading()) return false; + const QPointF actual = list->mapToItem(window->contentItem(), QPointF()); + if (actual.x() < at.x() || actual.x() + list->width() > at.x() + ancestor->width()) return false; + } + } + return true; + }; + columns->setProperty("currentIndex", backend.folder()->indexOf("last.txt")); + QCOMPARE(backend.folder()->selectedPaths(), QStringList{path + "/last.txt"}); + const double totalWidth = flick->property("contentWidth").toDouble(); + // Jump through the trail and resize it in both directions. + for (const double fraction : {0.0, 1.0, .5, .1, .9, .25, .75, 0.0, 1.0}) { + flick->setProperty("contentX", fraction * (totalWidth - flick->width())); + QTRY_VERIFY(visibleAncestorsLoaded()); + QTRY_VERIFY(loaded()); + QVERIFY(models().size() <= qCeil(flick->width() / 261) + 4); + QCOMPARE(flick->property("contentWidth").toDouble(), totalWidth); + QCOMPARE(backend.folder()->selectedPaths(), QStringList{path + "/last.txt"}); + } + // Unloading and restoring a column keeps the user's vertical position. + scrollTo(true); + QTRY_VERIFY(loaded() && listFor(home.path())); + auto *first = listFor(home.path()); + first->setProperty("contentY", first->property("originY").toReal() + 400); + const qreal saved = first->property("contentY").toReal(); + QPointer oldModel = modelFor(first); + // Keep an ongoing drag's source alive when the trail is scrolled. + // Internal drag exercises the same attached active state offscreen; + // the native desktop drag loop is outside this test's scope. + QPointer dragRow; + for (auto *item : items()) + if (item->property("trail").isValid() && item->property("path").toString().startsWith(home.path() + "/row")) { + dragRow = item; + break; + } + QVERIFY(dragRow); + QQmlExpression startDrag(qmlContext(dragRow), dragRow, "Drag.dragType = Drag.Internal; Drag.active = true"); + startDrag.evaluate(); + QVERIFY2(!startDrag.hasError(), qPrintable(startDrag.error().toString())); + scrollTo(false); + QCoreApplication::processEvents(); + QVERIFY2(dragRow && oldModel && qmlContext(dragRow) && qmlContext(dragRow)->isValid() && listFor(home.path()), "Scrolling unloaded the active drag source"); + QQmlExpression stopDrag(qmlContext(dragRow), dragRow, "Drag.active = false"); + stopDrag.evaluate(); + QVERIFY2(!stopDrag.hasError(), qPrintable(stopDrag.error().toString())); + QTRY_VERIFY(loaded() && !listFor(home.path())); + QTRY_VERIFY(oldModel.isNull()); + write(home.filePath("new.txt"), "new while hidden"); + scrollTo(true); + QTRY_VERIFY(loaded() && listFor(home.path()) && modelFor(listFor(home.path()))->indexOf("new.txt") >= 0); + QTRY_COMPARE(listFor(home.path())->property("contentY").toReal(), saved); + // Old rows can still be scrolled while a new listing waits to run. + { + auto *pool = QThreadPool::globalInstance(); + const int workers = pool->maxThreadCount(); + QSemaphore started, release; + bool released = false; + const auto drain = qScopeGuard([&] { + if (!released) release.release(workers); + pool->waitForDone(); + }); + QList> blockers; + for (int i = 0; i < workers; ++i) + blockers << QtConcurrent::run([&] { started.release(); release.acquire(); }); + QVERIFY(started.tryAcquire(workers, 5000)); + auto *list = listFor(home.path()); + auto *model = modelFor(list); + write(home.filePath("note.txt"), "changed size forces the model to reset"); + model->reload(); + QVERIFY(model->loading()); + list->setProperty("contentY", list->property("originY").toReal() + 800); + release.release(workers); + released = true; + QTRY_VERIFY(!model->loading()); + QTRY_COMPARE(list->property("contentY").toReal() - list->property("originY").toReal(), 800.0); + } + // A reset can destroy a drag's delegate; its column must not stay pinned. + QPointer resettingDragRow; + for (auto *item : items()) + if (item->property("trail").isValid() && item->property("path").toString().startsWith(home.path() + "/row")) { + resettingDragRow = item; + break; + } + QVERIFY(resettingDragRow); + QQmlExpression resetDrag(qmlContext(resettingDragRow), resettingDragRow, "Drag.dragType = Drag.Internal; Drag.active = true"); + resetDrag.evaluate(); + QVERIFY2(!resetDrag.hasError(), qPrintable(resetDrag.error().toString())); + write(home.filePath("note.txt"), "reset again"); + modelFor(listFor(home.path()))->reload(); + QTRY_VERIFY(loaded()); + QTRY_VERIFY(resettingDragRow.isNull()); + // Language changes while unloaded invalidate the remembered position. + scrollTo(false); + QTRY_VERIFY(loaded() && !listFor(home.path())); + write(home.filePath("language"), "de"); + QTRY_COMPARE(translator.language(), QString("de")); + scrollTo(true); + QTRY_VERIFY(loaded() && listFor(home.path())); + QTRY_VERIFY(listFor(home.path())->property("contentY").toReal() < 32); + listFor(home.path())->setProperty("contentY", 400); + // Settings changed while unloaded invalidate a remembered scroll, so + // the next folder in the trail is visible under the new ordering. + scrollTo(false); + QTRY_VERIFY(loaded() && !listFor(home.path())); + backend.folder()->setShowHidden(true); + backend.folder()->setSortMode("modified"); + write(home.filePath(".hidden"), "hidden"); + scrollTo(true); + QTRY_VERIFY(loaded() && listFor(home.path()) && modelFor(listFor(home.path()))->indexOf(".hidden") >= 0); + auto *restored = listFor(home.path()); + QVERIFY(modelFor(restored)->showHidden()); + QCOMPARE(modelFor(restored)->sortMode(), QString("modified")); + QTRY_VERIFY(restored->property("contentY").toReal() < 32); + // A folder can become much shorter while nobody watches it. + restored->setProperty("contentY", restored->property("originY").toReal() + 400); + scrollTo(false); + QTRY_VERIFY(loaded() && !listFor(home.path())); + for (int i = 0; i < 128; ++i) + QVERIFY(QFile::remove(home.filePath(QString("row%1.txt").arg(i, 3, 10, QChar('0'))))); + scrollTo(true); + QTRY_VERIFY(loaded() && listFor(home.path()) && modelFor(listFor(home.path()))->rowCount() == 5); + QTRY_VERIFY(qAbs(listFor(home.path())->property("contentY").toReal() - listFor(home.path())->property("originY").toReal()) < 1); + // Context menus and actual file drops still target the restored column. + QVERIFY(QTest::qWaitForWindowExposed(window)); + auto *target = listFor(home.path()); + const QPointF at = target->mapToItem(window->contentItem(), QPointF()); + const int mouseX = qRound((qMax(0.0, at.x()) + qMin(double(window->width()), at.x() + target->width())) / 2); + QSignalSpy menu(columns, SIGNAL(contextMenu(QVariant,QString,QPointF))); + QVERIFY(menu.isValid()); + QTest::mouseClick(window, Qt::RightButton, Qt::NoModifier, {mouseX, 16}); + QTRY_COMPARE(menu.count(), 1); + QCOMPARE(menu.at(0).at(1).toString(), home.path()); + QTest::mouseClick(window, Qt::RightButton, Qt::NoModifier, {mouseX, 250}); + QTRY_COMPARE(menu.count(), 2); + QCOMPARE(menu.at(1).at(1).toString(), home.path()); + QTemporaryDir incoming; + const auto drop = [&](const QString &name, int y) { + write(incoming.filePath(name), "dropped"); + QMimeData data; + data.setUrls({QUrl::fromLocalFile(incoming.filePath(name))}); + QDragEnterEvent enter(QPointF(mouseX, y), Qt::MoveAction, &data, Qt::LeftButton, Qt::NoModifier); + QGuiApplication::sendEvent(window, &enter); + QDropEvent drop(QPointF(mouseX, y), Qt::MoveAction, &data, Qt::LeftButton, Qt::NoModifier); + QGuiApplication::sendEvent(window, &drop); + return drop.isAccepted(); + }; + QSignalSpy dropped(&fileOps, &FileOps::done); + QVERIFY(drop("on-folder.txt", 16)); + QTRY_COMPARE(dropped.count(), 1); + QVERIFY(QFile::exists(home.filePath("step/on-folder.txt"))); + QVERIFY(drop("on-space.txt", 250)); + QTRY_COMPARE(dropped.count(), 2); + QVERIFY(QFile::exists(home.filePath("on-space.txt"))); + scrollTo(false); + QTRY_VERIFY(loaded()); + // The selection, look-inside column and deeper/back navigation survive. + columns->setProperty("currentIndex", backend.folder()->indexOf("peek")); + QTRY_VERIFY(listFor(path + "/peek") && !modelFor(listFor(path + "/peek"))->loading()); + QCOMPARE(modelFor(listFor(path + "/peek"))->rowCount(), 1); + backend.navigate(path + "/peek"); + QTRY_VERIFY(loaded() && visibleAncestorsLoaded()); + QVERIFY(models().size() <= 6); + backend.navigate(path); + QTRY_VERIFY(loaded()); + window->setWidth(1307); + QTRY_VERIFY(loaded()); + QVERIFY(models().size() <= 9); + window->setWidth(width); + QTRY_VERIFY(loaded()); + QVERIFY(models().size() <= 6); + backend.navigate(home.filePath("step")); + columns->setProperty("currentIndex", -1); + window->setWidth(1307); + QTRY_VERIFY(loaded() && visibleAncestorsLoaded()); + QCOMPARE(models().size(), 2); + // Toggle a peek, then change direction while the view is alive. + columns->setProperty("currentIndex", backend.folder()->indexOf("step")); + QTRY_VERIFY(loaded() && listFor(home.filePath("step/step")) && visibleAncestorsLoaded()); + object->setProperty("mirrored", !mirrored); + mirrored = !mirrored; + QTRY_VERIFY(loaded() && visibleAncestorsLoaded()); + columns->setProperty("currentIndex", -1); + QTRY_VERIFY(loaded() && visibleAncestorsLoaded()); + // A surviving ancestor now points at a different, distant child. + for (int i = 0; i < 48; ++i) + QVERIFY(QDir().mkdir(home.filePath(QString("branch%1").arg(i, 2, 10, QChar('0'))))); + QTRY_VERIFY(loaded() && listFor(home.path()) && modelFor(listFor(home.path()))->indexOf("branch47") >= 0); + QPointer sharedAncestor = modelFor(listFor(home.path())); + listFor(home.path())->setProperty("contentY", 0); + backend.navigate(home.filePath("branch47")); + QTRY_VERIFY(loaded()); + QCOMPARE(modelFor(listFor(home.path())), sharedAncestor.data()); + const auto trailShown = [&](const QString &path) { + for (auto *item : items()) { + if (!item->property("trail").toBool() || item->property("path").toString() != path) continue; + const qreal y = item->mapToItem(window->contentItem(), QPointF()).y(); + return y >= 0 && y + item->height() <= window->height(); + } + return false; + }; + QTRY_VERIFY(trailShown(home.filePath("branch47"))); + // The cache predates the new next child. An early empty reveal must + // not turn the automatic position into a saved zero before loaded. + backend.navigate(path); + QTRY_VERIFY(loaded() && !listFor(home.path())); + QVERIFY(QDir().mkdir(home.filePath("zz-new"))); + backend.navigate(home.filePath("zz-new")); + QTRY_VERIFY(loaded() && trailShown(home.filePath("zz-new"))); + } + void gridBandHitsTheTilesBothWays() { // The rubber band's hit areas sit where the grid put the tiles, also From 0e0e8b4b02fd6c4ab978b21c6db1181217435dcf Mon Sep 17 00:00:00 2001 From: Kirill Kostin <15322854+k-kostin@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:28:34 +0300 Subject: [PATCH 2/2] Retain recently shown columns within a bounded budget --- src/FolderColumns.qml | 61 +++++++++++++++++++++++++++------ tests/folio_tests.cpp | 80 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 120 insertions(+), 21 deletions(-) diff --git a/src/FolderColumns.qml b/src/FolderColumns.qml index 07ab4e7f..ced9ba40 100644 --- a/src/FolderColumns.qml +++ b/src/FolderColumns.qml @@ -30,7 +30,38 @@ Item { // column, and the rest stay as they are instead of reloading (which // flashed). ListModel { id: trail } + property bool complete: false + property bool syncingTrail: false + property var recentColumns: [] + // Two expanded viewports, including partial columns at their edges. + readonly property int columnLimit: 2 * (Math.ceil(flick.width / (columnWidth + 1)) + 3) + onColumnLimitChanged: updateColumns() + + function updateColumns() { + if (!complete || syncingTrail) return + const paths = [] + const near = [] + for (let i = 0; i < trail.count; ++i) { + const path = trail.get(i).path + paths.push(path) + const x = row.x + (view.LayoutMirroring.enabled + ? (trail.count - i) * (columnWidth + 1) + (inside.visible ? inside.width : 0) + : i * (columnWidth + 1)) + if (trail.count <= columnLimit || (x + columnWidth > flick.contentX - columnWidth + && x < flick.contentX + flick.width + columnWidth)) + near.push(path) + } + // Protect the whole current area before evicting old ones. Updating + // individual Loaders in binding order could evict a neighbour first. + const older = recentColumns.filter(path => paths.indexOf(path) >= 0 && near.indexOf(path) < 0) + const spare = Math.max(0, columnLimit - near.length) + const keep = older.slice(Math.max(0, older.length - spare)).concat(near) + if (keep.length !== recentColumns.length || keep.some((path, i) => path !== recentColumns[i])) + recentColumns = keep + } + function syncTrail() { + syncingTrail = true const want = [] for (let i = 0; i + 1 < crumbs.length; ++i) want.push({ path: crumbs[i].path, next: crumbs[i + 1].name }) @@ -45,14 +76,23 @@ Item { trail.remove(same, trail.count - same) if (same < want.length) trail.append(want.slice(same)) + syncingTrail = false + revealEnd() } onCrumbsChanged: syncTrail() - Component.onCompleted: syncTrail() + Component.onCompleted: { + syncTrail() + complete = true + revealEnd() + } readonly property var selected: here.currentIndex >= 0 ? (backend.folder.count, backend.folder.get(here.currentIndex)) : ({}) // Keeps the newest columns in view as the trail grows: on the left, // when mirrored. - function revealEnd() { flick.contentX = view.LayoutMirroring.enabled ? 0 : Math.max(0, flick.contentWidth - flick.width) } + function revealEnd() { + flick.contentX = view.LayoutMirroring.enabled ? 0 : Math.max(0, flick.contentWidth - flick.width) + updateColumns() + } LayoutMirroring.onEnabledChanged: revealEnd() function enter() { if (view.selected.isDir && (insideFolder.loading || insideFolder.count > 0 || insideFolder.error.length > 0)) @@ -73,6 +113,7 @@ Item { contentWidth: Math.max(width, row.width) contentHeight: height boundsBehavior: Flickable.StopAtBounds + onContentXChanged: view.updateColumns() onContentWidthChanged: view.revealEnd() onWidthChanged: view.revealEnd() ScrollBar.horizontal: FolioScrollBar { } @@ -82,14 +123,17 @@ Item { // A Row isn't placed by its Flickable, so it's moved to the right // edge by hand when mirrored. x: LayoutMirroring.enabled ? parent.width - width : 0 + // Known before Row's first layout, so RTL and revealEnd select + // the right initial area without loading a transient first one. + width: (trail.count + 1) * (view.columnWidth + 1) + (inside.visible ? inside.width : 0) height: flick.height + onXChanged: view.updateColumns() // The folders on the way here, each showing which way the trail goes. Repeater { model: trail Row { id: ancestor - required property int index required property string path required property string next width: view.columnWidth + 1 @@ -114,14 +158,9 @@ Item { property Item dragSource: null width: view.columnWidth height: parent.height - // Keep one column ready on either side. Its place in - // the row stays even when its model and rows are gone. - // Use the index, not Row's not-yet-laid-out x on creation. - readonly property real columnX: row.x + (view.LayoutMirroring.enabled - ? (trail.count - ancestor.index) * ancestor.width + (inside.visible ? inside.width : 0) - : ancestor.index * ancestor.width) - active: dragSource !== null || (columnX + width > flick.contentX - view.columnWidth - && columnX < flick.contentX + flick.width + view.columnWidth) + // Recently shown columns stay alive within the shared + // budget. A drag keeps its source until it finishes. + active: dragSource !== null || view.recentColumns.indexOf(ancestor.path) >= 0 sourceComponent: ListView { id: column width: view.columnWidth diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index fb5e0379..89690cc6 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -5742,7 +5742,7 @@ private slots: QVERIFY(!FolderModel::toMap(FolderModel::Entry()).contains("peek")); } - void columnsOnlyReadFoldersNearTheViewport_data() + void columnsKeepRecentFoldersWithinABudget_data() { QTest::addColumn("mirrored"); QTest::addColumn("width"); @@ -5752,7 +5752,7 @@ private slots: QTest::newRow("narrow-mirrored") << true << 180; } - void columnsOnlyReadFoldersNearTheViewport() + void columnsKeepRecentFoldersWithinABudget() { QFETCH(bool, mirrored); QFETCH(int, width); @@ -5834,6 +5834,16 @@ private slots: const auto scrollTo = [&](bool oldest) { flick->setProperty("contentX", oldest != mirrored ? 0 : flick->property("contentWidth").toDouble() - flick->width()); }; + const auto leaveOldest = [&] { + // Visit enough disjoint areas to really evict the root, rather + // than accidentally testing a watcher in a still-resident column. + for (const double fraction : {.25, .5, .75}) { + flick->setProperty("contentX", fraction * (flick->property("contentWidth").toDouble() - flick->width())); + QCoreApplication::processEvents(); + } + scrollTo(false); + }; + const auto modelLimit = [&] { return 2 * (qCeil(flick->width() / 261) + 3) + 2; }; const auto visibleAncestorsLoaded = [&] { // Use actual Row geometry, independently of Loader's index formula. const QPointF viewport = flick->mapToItem(window->contentItem(), QPointF()); @@ -5857,13 +5867,60 @@ private slots: flick->setProperty("contentX", fraction * (totalWidth - flick->width())); QTRY_VERIFY(visibleAncestorsLoaded()); QTRY_VERIFY(loaded()); - QVERIFY(models().size() <= qCeil(flick->width() / 261) + 4); + QVERIFY(models().size() <= modelLimit()); QCOMPARE(flick->property("contentWidth").toDouble(), totalWidth); QCOMPARE(backend.folder()->selectedPaths(), QStringList{path + "/last.txt"}); } // Unloading and restoring a column keeps the user's vertical position. scrollTo(true); QTRY_VERIFY(loaded() && listFor(home.path())); + QPointer warmModel = modelFor(listFor(home.path())); + QHash> warmAreas; + const auto rememberArea = [&] { + const qreal left = flick->mapToItem(window->contentItem(), QPointF()).x(); + for (auto *item : items()) { + auto *model = modelFor(item); + if (!model || model == backend.folder() || model->path().isEmpty()) continue; + const qreal x = item->mapToItem(window->contentItem(), QPointF()).x(); + if (x + item->width() > left - 260 && x < left + flick->width() + 260) + warmAreas.insert(model->path(), model); + } + }; + rememberArea(); + scrollTo(false); + QTRY_VERIFY(loaded()); + QVERIFY2(listFor(home.path()) && modelFor(listFor(home.path())) == warmModel, + "Switching between recent areas discarded the warm column"); + rememberArea(); + for (int jump = 0; jump < 8; ++jump) { + scrollTo(jump % 2 == 0); + QTRY_VERIFY(loaded() && visibleAncestorsLoaded()); + for (const auto &model : warmAreas) { + QVERIFY(model); + QVERIFY(listFor(model->path())); + QCOMPARE(modelFor(listFor(model->path())), model.data()); + } + QVERIFY(models().size() <= modelLimit()); + } + // Retained but invisible models keep receiving settings and file changes. + write(home.filePath(".retained-hidden"), "x"); + backend.folder()->setShowHidden(true); + backend.folder()->setSortMode("oldest"); + QTRY_VERIFY(warmModel && !warmModel->loading() && warmModel->showHidden() && warmModel->sortMode() == "oldest" + && warmModel->indexOf(".retained-hidden") >= 0); + write(home.filePath("language"), "de"); + QTRY_COMPARE(translator.language(), QString("de")); + QTRY_VERIFY(warmModel && !warmModel->loading()); + QVERIFY(listFor(home.path()) && modelFor(listFor(home.path())) == warmModel); + write(home.filePath("language"), "en"); + QTRY_COMPARE(translator.language(), QString("en")); + backend.folder()->setShowHidden(false); + backend.folder()->setSortMode("name"); + QVERIFY(QFile::remove(home.filePath(".retained-hidden"))); + QTRY_VERIFY(loaded()); + scrollTo(true); + QTRY_VERIFY(loaded() && listFor(home.path())); + QCOMPARE(modelFor(listFor(home.path())), warmModel.data()); auto *first = listFor(home.path()); first->setProperty("contentY", first->property("originY").toReal() + 400); const qreal saved = first->property("contentY").toReal(); @@ -5881,8 +5938,9 @@ private slots: QQmlExpression startDrag(qmlContext(dragRow), dragRow, "Drag.dragType = Drag.Internal; Drag.active = true"); startDrag.evaluate(); QVERIFY2(!startDrag.hasError(), qPrintable(startDrag.error().toString())); - scrollTo(false); + leaveOldest(); QCoreApplication::processEvents(); + QVERIFY(models().size() <= modelLimit() + 1); QVERIFY2(dragRow && oldModel && qmlContext(dragRow) && qmlContext(dragRow)->isValid() && listFor(home.path()), "Scrolling unloaded the active drag source"); QQmlExpression stopDrag(qmlContext(dragRow), dragRow, "Drag.active = false"); stopDrag.evaluate(); @@ -5934,7 +5992,7 @@ private slots: QTRY_VERIFY(loaded()); QTRY_VERIFY(resettingDragRow.isNull()); // Language changes while unloaded invalidate the remembered position. - scrollTo(false); + leaveOldest(); QTRY_VERIFY(loaded() && !listFor(home.path())); write(home.filePath("language"), "de"); QTRY_COMPARE(translator.language(), QString("de")); @@ -5944,7 +6002,7 @@ private slots: listFor(home.path())->setProperty("contentY", 400); // Settings changed while unloaded invalidate a remembered scroll, so // the next folder in the trail is visible under the new ordering. - scrollTo(false); + leaveOldest(); QTRY_VERIFY(loaded() && !listFor(home.path())); backend.folder()->setShowHidden(true); backend.folder()->setSortMode("modified"); @@ -5957,7 +6015,7 @@ private slots: QTRY_VERIFY(restored->property("contentY").toReal() < 32); // A folder can become much shorter while nobody watches it. restored->setProperty("contentY", restored->property("originY").toReal() + 400); - scrollTo(false); + leaveOldest(); QTRY_VERIFY(loaded() && !listFor(home.path())); for (int i = 0; i < 128; ++i) QVERIFY(QFile::remove(home.filePath(QString("row%1.txt").arg(i, 3, 10, QChar('0'))))); @@ -6003,15 +6061,15 @@ private slots: QCOMPARE(modelFor(listFor(path + "/peek"))->rowCount(), 1); backend.navigate(path + "/peek"); QTRY_VERIFY(loaded() && visibleAncestorsLoaded()); - QVERIFY(models().size() <= 6); + QVERIFY(models().size() <= modelLimit()); backend.navigate(path); QTRY_VERIFY(loaded()); window->setWidth(1307); QTRY_VERIFY(loaded()); - QVERIFY(models().size() <= 9); + QVERIFY(models().size() <= modelLimit()); window->setWidth(width); QTRY_VERIFY(loaded()); - QVERIFY(models().size() <= 6); + QVERIFY(models().size() <= modelLimit()); backend.navigate(home.filePath("step")); columns->setProperty("currentIndex", -1); window->setWidth(1307); @@ -6046,6 +6104,8 @@ private slots: // The cache predates the new next child. An early empty reveal must // not turn the automatic position into a saved zero before loaded. backend.navigate(path); + QTRY_VERIFY(loaded()); + leaveOldest(); QTRY_VERIFY(loaded() && !listFor(home.path())); QVERIFY(QDir().mkdir(home.filePath("zz-new"))); backend.navigate(home.filePath("zz-new"));