diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 6a4ddbbe..31765a28 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -1,32 +1,33 @@ if(NOT Qt5QuickTest_FOUND) message(STATUS "Qt5QuickTest not found, autotests will not be built.") return() endif() add_executable(qmltest qmltest.cpp) target_link_libraries(qmltest Qt5::QuickTest) macro(kirigami_add_tests) if (WIN32) set(_extra_args -platform offscreen) endif() foreach(test ${ARGV}) add_test(NAME ${test} COMMAND qmltest ${_extra_args} -import ${CMAKE_BINARY_DIR}/bin -input ${test} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} ) endforeach() endmacro() kirigami_add_tests( tst_keynavigation.qml tst_listskeynavigation.qml tst_pagerow.qml tst_icon.qml tst_actiontoolbar.qml + pagepool/tst_pagepool.qml ) diff --git a/autotests/pagepool/TestPage.qml b/autotests/pagepool/TestPage.qml new file mode 100644 index 00000000..d4554054 --- /dev/null +++ b/autotests/pagepool/TestPage.qml @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2020 Mason McParlane + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +import QtQuick 2.7 +import org.kde.kirigami 2.11 as Kirigami + +Kirigami.Page { + title: qsTr("INITIAL TITLE") +} diff --git a/autotests/pagepool/tst_pagepool.qml b/autotests/pagepool/tst_pagepool.qml new file mode 100644 index 00000000..4fcc0472 --- /dev/null +++ b/autotests/pagepool/tst_pagepool.qml @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: 2020 Mason McParlane + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +import QtQuick 2.7 +import QtQuick.Controls 2.0 +import QtQuick.Window 2.1 +import org.kde.kirigami 2.11 as Kirigami +import QtTest 1.0 + +TestCase { + id: testCase + width: 400 + height: 400 + name: "PagePool" + + function initTestCase() { + mainWindow.show() + } + + function cleanupTestCase() { + mainWindow.close() + } + + function applicationWindow() { return mainWindow; } + + Kirigami.ApplicationWindow { + id: mainWindow + width: 480 + height: 360 + } + + Kirigami.PagePool { + id: pool + } + + function init() { + mainWindow.pageStack.clear() + pool.clear() + } + + // Queries added to page URLs ensure the PagePool can + // have multiple instances of TestPage.qml + + Kirigami.PagePoolAction { + id: loadPageAction + pagePool: pool + pageStack: mainWindow.pageStack + page: "TestPage.qml?action=loadPageAction" + } + + function test_loadPage () { + var expectedUrl = "TestPage.qml?action=loadPageAction" + compare(mainWindow.pageStack.depth, 0) + loadPageAction.trigger() + compare(mainWindow.pageStack.depth, 1) + verify(pool.lastLoadedUrl.toString().endsWith(expectedUrl)) + compare(mainWindow.pageStack.currentItem.title, "INITIAL TITLE") + } + + Kirigami.PagePoolAction { + id: loadPageActionWithProps + pagePool: pool + pageStack: mainWindow.pageStack + page: "TestPage.qml?action=loadPageActionWithProps" + initialProperties: { + return {title: "NEW TITLE" } + } + } + + function test_loadPageInitialPropertyOverride () { + var expectedUrl = "TestPage.qml?action=loadPageActionWithProps" + compare(mainWindow.pageStack.depth, 0) + loadPageActionWithProps.trigger() + compare(mainWindow.pageStack.depth, 1) + verify(pool.lastLoadedUrl.toString().endsWith(expectedUrl)) + compare(mainWindow.pageStack.currentItem.title, "NEW TITLE") + compare(pool.lastLoadedItem.title, "NEW TITLE") + } + + Kirigami.PagePoolAction { + id: loadPageActionPropsNotObject + pagePool: pool + pageStack: mainWindow.pageStack + page: "TestPage.qml?action=loadPageActionPropsNotObject" + initialProperties: "This is a string not an object..." + } + + function test_loadPageInitialPropertiesWrongType () { + var expectedUrl = "TestPage.qml?action=loadPageAction" + compare(mainWindow.pageStack.depth, 0) + loadPageAction.trigger() + loadPageActionPropsNotObject.trigger() + compare(mainWindow.pageStack.depth, 1) + verify(pool.lastLoadedUrl.toString().endsWith(expectedUrl)) + } + + Kirigami.PagePoolAction { + id: loadPageActionPropDoesNotExist + pagePool: pool + pageStack: mainWindow.pageStack + page: "TestPage.qml?action=loadPageActionPropDoesNotExist" + initialProperties: { + return { propDoesNotExist: "PROP-NON-EXISTANT" } + } + } + + function test_loadPageInitialPropertyNotExistOkay () { + var expectedUrl = "TestPage.qml?action=loadPageActionPropDoesNotExist" + loadPageActionPropDoesNotExist.trigger() + verify(pool.lastLoadedUrl.toString().endsWith(expectedUrl)) + } +} diff --git a/src/controls/PagePoolAction.qml b/src/controls/PagePoolAction.qml index b60533a5..1f58da8b 100644 --- a/src/controls/PagePoolAction.qml +++ b/src/controls/PagePoolAction.qml @@ -1,102 +1,107 @@ /* * SPDX-FileCopyrightText: 2016 Marco Martin * * SPDX-License-Identifier: LGPL-2.0-or-later */ import QtQuick 2.7 import QtQuick.Controls 2.5 as Controls import org.kde.kirigami 2.11 as Kirigami /** * An action used to load Pages coming from a common PagePool * in a PageRow or QtQuickControls2 StackView * * @inherit Action */ Kirigami.Action { id: root /** * page: string * Url or filename of the page this action will load */ property string page /** * pagePool: Kirigami.PagePool * The PagePool used by this PagePoolAction. * PagePool will make sure only one instance of the page identified by the page url will be created and reused. *PagePool's lastLoaderUrl property will be used to control the mutual * exclusivity of the checked state of the PagePoolAction instances * sharing the same PagePool */ property Kirigami.PagePool pagePool /** * pageStack: Kirigami.PageRow or QtQuickControls2 StackView * The component that will instantiate the pages, which has to work with a stack logic. * Kirigami.PageRow is recommended, but will work with QtQuicControls2 StackView as well. * By default this property is binded to ApplicationWindow's global * pageStack, which is a PageRow by default. */ property Item pageStack: typeof applicationWindow != undefined ? applicationWindow().pageStack : null /** * basePage: Kirigami.Page * The page of pageStack new pages will be pushed after. * All pages present after the given basePage will be removed from the pageStack */ property Controls.Page basePage /** * initialProperties: JavaScript Object * The initialProperties object specifies a map of initial property values for the created page * when it is pushed onto the Kirigami.PagePool. */ property var initialProperties checked: pagePool && pagePool.resolvedUrl(page) == pagePool.lastLoadedUrl onTriggered: { if (page.length == 0 || !pagePool || !pageStack) { return; } + if (initialProperties && typeof(initialProperties) !== "object") { + console.warn("initialProperties must be of type object"); + return; + } + if (pagePool.resolvedUrl(page) == pagePool.lastLoadedUrl) { return; } if (!pageStack.hasOwnProperty("pop") || typeof pageStack.pop !== "function" || !pageStack.hasOwnProperty("push") || typeof pageStack.push !== "function") { return; } if (pagePool.isLocalUrl(page)) { if (basePage) { pageStack.pop(basePage); } else { pageStack.clear(); } pageStack.push(initialProperties ? pagePool.loadPageWithProperties(page, initialProperties) : pagePool.loadPage(page)); } else { var callback = function(item) { if (basePage) { pageStack.pop(basePage); } else { pageStack.clear(); } pageStack.push(item); }; if (initialProperties) { pagePool.loadPage(page, initialProperties, callback); } else { pagePool.loadPage(page, callback); } } } } diff --git a/src/pagepool.cpp b/src/pagepool.cpp index 9a0408b3..880409ce 100644 --- a/src/pagepool.cpp +++ b/src/pagepool.cpp @@ -1,270 +1,295 @@ /* * SPDX-FileCopyrightText: 2019 Marco Martin * * SPDX-License-Identifier: LGPL-2.0-or-later */ #include "pagepool.h" #include #include #include #include #include PagePool::PagePool(QObject *parent) : QObject(parent) { } PagePool::~PagePool() { } QUrl PagePool::lastLoadedUrl() const { return m_lastLoadedUrl; } +QQuickItem *PagePool::lastLoadedItem() const +{ + return m_lastLoadedItem; +} + void PagePool::setCachePages(bool cache) { if (cache == m_cachePages) { return; } if (cache) { for (auto *c : m_componentForUrl.values()) { c->deleteLater(); } m_componentForUrl.clear(); for (auto *i : m_itemForUrl.values()) { // items that had been deparented are safe to delete if (!i->parentItem()) { i->deleteLater(); } QQmlEngine::setObjectOwnership(i, QQmlEngine::JavaScriptOwnership); } m_itemForUrl.clear(); } m_cachePages = cache; emit cachePagesChanged(); } bool PagePool::cachePages() const { return m_cachePages; } QQuickItem *PagePool::loadPage(const QString &url, QJSValue callback) { return loadPageWithProperties(url, QVariantMap(), callback); } QQuickItem *PagePool::loadPageWithProperties( const QString &url, const QVariantMap &properties, QJSValue callback) { Q_ASSERT(qmlEngine(this)); QQmlContext *ctx = QQmlEngine::contextForObject(this); Q_ASSERT(ctx); const QUrl actualUrl = resolvedUrl(url); QQuickItem *foundItem = nullptr; if (actualUrl == m_lastLoadedUrl && m_lastLoadedItem) { foundItem = m_lastLoadedItem; } else if (m_itemForUrl.contains(actualUrl)) { foundItem = m_itemForUrl[actualUrl]; } if (foundItem) { if (callback.isCallable()) { QJSValueList args = {qmlEngine(this)->newQObject(foundItem)}; callback.call(args); m_lastLoadedUrl = actualUrl; emit lastLoadedUrlChanged(); // We could return the item, but for api coherence return null return nullptr; } else { m_lastLoadedUrl = actualUrl; emit lastLoadedUrlChanged(); return foundItem; } } QQmlComponent *component = m_componentForUrl.value(actualUrl); if (!component) { component = new QQmlComponent(qmlEngine(this), actualUrl, QQmlComponent::PreferSynchronous); } if (component->status() == QQmlComponent::Loading) { if (!callback.isCallable()) { component->deleteLater(); m_componentForUrl.remove(actualUrl); return nullptr; } connect(component, &QQmlComponent::statusChanged, this, [this, component, callback, properties] (QQmlComponent::Status status) mutable { if (status != QQmlComponent::Ready) { qWarning() << component->errors(); m_componentForUrl.remove(component->url()); component->deleteLater(); return; } QQuickItem *item = createFromComponent(component, properties); if (item) { QJSValueList args = {qmlEngine(this)->newQObject(item)}; callback.call(args); } if (m_cachePages) { component->deleteLater(); } else { m_componentForUrl[component->url()] = component; } }); return nullptr; } else if (component->status() != QQmlComponent::Ready) { qWarning() << component->errors(); return nullptr; } QQuickItem *item = createFromComponent(component, properties); if (m_cachePages) { component->deleteLater(); } else { m_componentForUrl[component->url()] = component; } if (callback.isCallable()) { QJSValueList args = {qmlEngine(this)->newQObject(item)}; callback.call(args); m_lastLoadedUrl = actualUrl; emit lastLoadedUrlChanged(); // We could return the item, but for api coherence return null return nullptr; } else { m_lastLoadedUrl = actualUrl; emit lastLoadedUrlChanged(); return item; } } QQuickItem *PagePool::createFromComponent(QQmlComponent *component, const QVariantMap &properties) { QQmlContext *ctx = QQmlEngine::contextForObject(this); Q_ASSERT(ctx); //TODO: As soon as we can depend on Qt 5.14, use QQmlComponent::createWithInitialProperties QObject *obj = component->beginCreate(ctx); // Error? if (!obj) { return nullptr; } - auto it = properties.constBegin(); - while (it != properties.constEnd()) { - obj->setProperty(it.key().toUtf8().data(), it.value()); - ++it; + for (auto it = properties.constBegin(); it != properties.constEnd(); ++it) { + + QQmlProperty p(obj, it.key(), ctx); + if (!p.isValid()) { + qWarning() << "Invalid property " << it.key(); + continue; + } + if (!p.write(it.value())) { + qWarning() << "Could not set property " << it.key(); + continue; + } } component->completeCreate(); QQuickItem *item = qobject_cast(obj); if (!item) { obj->deleteLater(); return nullptr; } // Always cache just the last one m_lastLoadedItem = item; if (m_cachePages) { QQmlEngine::setObjectOwnership(item, QQmlEngine::CppOwnership); m_itemForUrl[component->url()] = item; } else { QQmlEngine::setObjectOwnership(item, QQmlEngine::JavaScriptOwnership); } + emit lastLoadedItemChanged(); + return item; } QUrl PagePool::resolvedUrl(const QString &stringUrl) const { Q_ASSERT(qmlEngine(this)); QQmlContext *ctx = QQmlEngine::contextForObject(this); Q_ASSERT(ctx); QUrl actualUrl(stringUrl); if (actualUrl.scheme().isEmpty()) { actualUrl = ctx->resolvedUrl(actualUrl); } return actualUrl; } bool PagePool::isLocalUrl(const QUrl &url) { return url.isLocalFile() || url.scheme().isEmpty() || url.scheme() == QStringLiteral("qrc"); } QUrl PagePool::urlForPage(QQuickItem *item) const { return m_urlForItem.value(item); } bool PagePool::contains(const QVariant &page) const { if (page.canConvert()) { return m_urlForItem.contains(page.value()); } else if (page.canConvert()) { const QUrl actualUrl = resolvedUrl(page.value()); return m_itemForUrl.contains(actualUrl); } else { return false; } } void PagePool::deletePage(const QVariant &page) { if (!contains(page)) { return; } QQuickItem *item; if (page.canConvert()) { item = page.value(); } else if (page.canConvert()) { QString url = page.value(); if (url.isEmpty()) { return; } const QUrl actualUrl = resolvedUrl(page.value()); item = m_itemForUrl.value(actualUrl); } else { return; } if (!item) { return; } const QUrl url = m_urlForItem.value(item); if (url.isEmpty()) { return; } m_itemForUrl.remove(url); m_urlForItem.remove(item); item->deleteLater(); } +void PagePool::clear() +{ + for (const auto& url : m_urlForItem) { + deletePage(url); + } + m_lastLoadedUrl = QUrl(); + m_lastLoadedItem = nullptr; + + emit lastLoadedUrlChanged(); + emit lastLoadedItemChanged(); +} #include "moc_pagepool.cpp" diff --git a/src/pagepool.h b/src/pagepool.h index 175e6acc..cb07d2d6 100644 --- a/src/pagepool.h +++ b/src/pagepool.h @@ -1,105 +1,117 @@ /* * SPDX-FileCopyrightText: 2019 Marco Martin * * SPDX-License-Identifier: LGPL-2.0-or-later */ #pragma once #include #include #include /** * A Pool of Page items, pages will be unique per url and the items * will be kept around unless explicitly deleted. * Instaces are C++ owned and can be deleted only manually using deletePage() * Instance are unique per url: if you need 2 different instance for a page * url, you should instantiate them in the traditional way * or use a different PagePool instance. */ class PagePool : public QObject { Q_OBJECT /** * The last url that was loaded with @loadPage. Useful if you need * to have a "checked" state to buttons or list items that * load the page when clicked. */ Q_PROPERTY(QUrl lastLoadedUrl READ lastLoadedUrl NOTIFY lastLoadedUrlChanged) + /** + * The last item that was loaded with @loadPage. + */ + Q_PROPERTY(QQuickItem *lastLoadedItem READ lastLoadedItem NOTIFY lastLoadedItemChanged) + /** * If true (default) the pages will be kept around, will have C++ ownership and only one instance per page will be created. * If false the pages will have Javascript ownership (thus deleted on pop by the page stacks) and each call to loadPage will create a new page instance. When cachePages is false, Components gets cached never the less */ Q_PROPERTY(bool cachePages READ cachePages WRITE setCachePages NOTIFY cachePagesChanged) public: PagePool(QObject *parent = nullptr); ~PagePool(); QUrl lastLoadedUrl() const; + QQuickItem *lastLoadedItem() const; void setCachePages(bool cache); bool cachePages() const; /** * Returns the instance of the item defined in the QML file identified * by url, only one instance will be made per url if cachePAges is true. If the url is remote (i.e. http) don't rely on the return value but us the async callback instead * @param url full url of the item: it can be a well formed Url, * an absolute path * or a relative one to the path of the qml file the PagePool is instantiated from * @param callback If we are loading a remote url, we can't have the item immediately but will be passed as a parameter to the provided callback. * Normally, don't set a callback, use it only in case of remote urls. * @returns the page instance that will have been created if necessary. * If the url is remote it will return null, * as well will return null if the callback has been provided */ Q_INVOKABLE QQuickItem *loadPage(const QString &url, QJSValue callback = QJSValue()); Q_INVOKABLE QQuickItem *loadPageWithProperties( const QString &url, const QVariantMap &properties, QJSValue callback = QJSValue()); /** * @returns The url of the page for the given instance, empty if there is no correspondence */ Q_INVOKABLE QUrl urlForPage(QQuickItem *item) const; /** * @returns true if the is managed by the PagePool * @param the page can be either a QQuickItem or an url */ Q_INVOKABLE bool contains(const QVariant &page) const; /** * Deletes the page (only if is managed by the pool. * @param page either the url or the instance of the page */ Q_INVOKABLE void deletePage(const QVariant &page); /** * @returns full url from an absolute or relative path */ Q_INVOKABLE QUrl resolvedUrl(const QString &file) const; /** * @returns true if the url identifies a local resource (local file or a file inside Qt's resource system). * False if the url points to a network location */ Q_INVOKABLE bool isLocalUrl(const QUrl &url); + /** + * Deletes all pages managed by the pool. + */ + Q_INVOKABLE void clear(); + Q_SIGNALS: void lastLoadedUrlChanged(); + void lastLoadedItemChanged(); void cachePagesChanged(); private: QQuickItem *createFromComponent(QQmlComponent *component, const QVariantMap &properties); QUrl m_lastLoadedUrl; QPointer m_lastLoadedItem; QHash m_itemForUrl; QHash m_componentForUrl; QHash m_urlForItem; bool m_cachePages = true; };