diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -25,6 +25,7 @@ mnemonicattached.cpp wheelhandler.cpp shadowedrectangle.cpp + pagerouter.cpp scenegraph/shadowedrectanglenode.cpp scenegraph/shadowedrectanglematerial.cpp scenegraph/shadowedborderrectanglematerial.cpp diff --git a/src/controls/PageRow.qml b/src/controls/PageRow.qml --- a/src/controls/PageRow.qml +++ b/src/controls/PageRow.qml @@ -56,6 +56,9 @@ */ contentItem: columnView + // Private handle to columnView. + property alias _columnView: columnView + /** * items: list * All the items that are present in the PageRow diff --git a/src/kirigamiplugin.cpp b/src/kirigamiplugin.cpp --- a/src/kirigamiplugin.cpp +++ b/src/kirigamiplugin.cpp @@ -18,6 +18,7 @@ #include "scenepositionattached.h" #include "wheelhandler.h" #include "shadowedrectangle.h" +#include "pagerouter.h" #include #include @@ -240,6 +241,8 @@ qmlRegisterType(uri, 2, 12, "ShadowedRectangle"); qmlRegisterUncreatableType(uri, 2, 12, "BorderGroup", QStringLiteral("Used as grouped property")); qmlRegisterUncreatableType(uri, 2, 12, "ShadowGroup", QStringLiteral("Used as grouped property")); + qmlRegisterType(uri, 2, 12, "PageRouter"); + qmlRegisterUncreatableType(uri, 2, 12, "PageRouterAttached", QStringLiteral("PageRouterAttached cannot be created")); qmlProtectModule(uri, 2); } diff --git a/src/pagerouter.h b/src/pagerouter.h new file mode 100644 --- /dev/null +++ b/src/pagerouter.h @@ -0,0 +1,392 @@ +/* + * SPDX-FileCopyrightText: 2020 Carson Black + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +#pragma once + +#include +#include "columnview.h" + +struct ParsedRoute { + QString name; + QVariant data; + QObject* item; + bool operator==(const ParsedRoute& rhs) + { + return name == rhs.name && data == rhs.data && item == rhs.item; + } +}; + +class PageRouterAttached; + +/** + * Item managing pages and data in named routes. + * + * @see PageRouterAttached + */ +class PageRouter : public QQuickItem +{ + Q_OBJECT + + /** + * @brief The named routes a PageRouter can navigate to. + * + * `routes` is a map of names to components representing Pages. + * + * @code{.qml} + * import QtQuick 2.12 + * import org.kde.kirigami 2.12 as Kirigami + * + * Kirigami.PageRouter { + * Component { + * id: homeComponent + * Kirigami.Page { + * // Page contents... + * } + * } + * Component { + * id: loginComponent + * Kirigami.Page { + * // Page contents... + * } + * } + * routes: { + * "/home": homeComponent, + * "/login": loginComponent, + * } + * } + * @endcode + */ + Q_PROPERTY(QJSValue routes READ routes WRITE setRoutes NOTIFY routesChanged) + + /** + * @brief The name of the initial route. + * + * `initialRoute` is the name of the page that the PageRouter will push upon + * creation. Changing it after creation will cause the PageRouter to reset + * its state. Not providing an `initialRoute` will result in undefined + * behavior. + * + * @code{.qml} + * import QtQuick 2.12 + * import org.kde.kirigami 2.12 as Kirigami + * + * Kirigami.PageRouter { + * Component { + * id: homeComponent + * Kirigami.Page { + * // Page contents... + * } + * } + * routes: { + * "/home": homeComponent + * } + * initialRoute: "/home" + * } + * @endcode + */ + Q_PROPERTY(QJSValue initialRoute READ initialRoute WRITE setInitialRoute NOTIFY initialRouteChanged) + + /** + * @brief The ColumnView being puppeted by the PageRouter. + * + * In normal usage, you should not need to change this property, except for + * puppeting an ApplicationWindow's ColumnView. + * + * @warning You should **not** directly interact with a ColumnView being puppeted + * by a PageRouter. Instead, use a PageRouter's functions to manipulate the + * ColumnView. + * + * @code{.qml} + * import QtQuick 2.12 + * import org.kde.kirigami 2.12 as Kirigami + * + * Kirigami.ApplicationWindow { + * Kirigami.PageRouter { + * pageRow: parent.pageStack + * Component { + * id: homeComponent + * Kirigami.Page { + * // Page contents... + * } + * } + * routes: { "/home": homeComponent } + * initialRoute: "/home" + * } + * } + * @endcode + */ + Q_PROPERTY(ColumnView* columnView MEMBER m_columnView) + + /** + * @brief Whether the PageRouter should cache pages. + * + * Note that you cannot have multiple identical pages on the current stack + * of routes when caching is enabled. + * + * This code will work: + * + * @code{.qml} + * import QtQuick 2.12 + * import org.kde.kirigami 2.12 as Kirigami + * + * Kirigami.PageRouter { + * Component { + * id: homeComponent + * Kirigami.Page { + * // Page contents... + * } + * } + * routes: { "/home": homeComponent } + * initialRoute: "/home" + * Component.onCompleted: navigateToRoute("/home", "/home") + * } + * @endcode + * + * This code will not work: + * + * @code{.qml} + * import QtQuick 2.12 + * import org.kde.kirigami 2.12 as Kirigami + * + * Kirigami.PageRouter { + * Component { + * id: homeComponent + * Kirigami.Page { + * // Page contents... + * } + * } + * routes: { "/home": homeComponent } + * initialRoute: "/home" + * cachePages: true + * Component.onCompleted: navigateToRoute("/home", "/home") + * } + * @endcode + */ + Q_PROPERTY(bool cachePages MEMBER m_cachePages NOTIFY cachePagesChanged) + +private: + /** + * @brief The routes the PageRouter is aware of. + * + * m_routes is the raw QJSValue from QML that will be + * parsed into a list of ParsedRoute struct as necessary + * Generally, this should not be mutated from C++, only read. + */ + QJSValue m_routes; + + /** + * @brief The PageRouter being puppeted. + * + * m_pageRow is the ColumnView this PageRouter puppets. + */ + ColumnView* m_columnView = nullptr; + + /** + * @brief The route that the PageRouter will load on completion. + * + * m_initialRoute is the raw QJSValue from QML that will be + * parsed into a ParsedRoute struct on construction. + * Generally, this should not be mutated from C++, only read. + */ + QJSValue m_initialRoute; + + /** + * @brief The current routes pushed on the PageRow. + * + * Generally, the state of m_pageRow and m_currentRoutes + * should be kept in sync. Undesirable behaviour will result + * from desynchronisation of the two. + */ + QList m_currentRoutes; + + /** + * @brief Cached routes. + * + * A list of ParsedRoutes with instantiated items. + */ + QList m_cachedRoutes; + + /** + * @see cachePages + */ + bool m_cachePages; + + /** + * @brief Helper function to push a route. + * + * This function has the shared logic between + * navigateToRoute and pushRoute. + */ + void push(ParsedRoute route); + + /** + * @brief Helper function to access whether m_routes has a key. + * + * This function abstracts the QJSValue. + */ + bool routesContainsKey(const QString &key); + + /** + * @brief Helper function to access the component of a key for m_routes. + * + * The return value will be a nullptr if @p key does not exist in + * m_routes. + */ + QQmlComponent *routesValueForKey(const QString &key); + +protected: + void classBegin() override; + void componentComplete() override; + +public: + PageRouter(QQuickItem *parent = nullptr); + ~PageRouter(); + + QJSValue routes() const; + void setRoutes(QJSValue routes); + + QJSValue initialRoute() const; + void setInitialRoute(QJSValue initialRoute); + + /** + * @brief Navigate to the given route. + * + * Calling `navigateToRoute` causes the PageRouter to replace currently + * active pages with the new route. + * + * @param route The given route for the PageRouter to navigate to. + * A route is an array of variants or a single item. A string item will be interpreted + * as a page without associated data. An object item will be interpreted + * as follows: + * @code{.js} + * { + * "route": "/home" // The named page of the route. + * "data": QtObject {} // The data to pass to the page. + * } + * @endcode + * Navigating to a route not defined in a PageRouter's routes is undefined + * behavior. + * + * @code{.qml} + * Button { + * text: "Login" + * onClicked: { + * Kirigami.PageRouter.navigateToRoute(["/home", "/login"]) + * } + * } + * @endcode + */ + Q_INVOKABLE void navigateToRoute(QJSValue route); + + /** + * @brief Check whether the current route is on the stack. + * + * `isNavigatedToRoute` will return true if the given route + * is on the stack. + * + * @param route The given route to check for. + * + * `isNavigatedToRoute` returns true for partial routes like + * the following: + * + * @code{.js} + * Kirigami.PageRouter.navigateToRoute(["/home", "/login", "/google"]) + * Kirigami.PageRouter.isNavigatedToRoute(["/home", "/login"]) // returns true + * @endcode + * + * This only works from the root page, e.g. the following will return false: + * @code{.js} + * Kirigami.PageRouter.navigateToRoute(["/home", "/login", "/google"]) + * Kirigami.PageRouter.isNavigatedToRoute(["/login", "/google"]) // returns false + * @endcode + */ + Q_INVOKABLE bool isNavigatedToRoute(QJSValue route); + + /** + * @brief Appends a route to the currently navigated route. + * + * Calling `pushRoute` will append the given @p route to the currently navigated + * routes. See navigateToRoute() if you want to replace the items currently on + * the PageRouter. + * + * @param route The given route to push. + * + * @code{.js} + * Kirigami.PageRouter.navigateToRoute(["/home", "/login"]) + * // The PageRouter is navigated to /home/login + * Kirigami.PageRouter.pushRoute("/google") + * // The PageRouter is navigated to /home/login/google + * @endcode + */ + Q_INVOKABLE void pushRoute(QJSValue route); + + /** + * @brief Pops the last page on the router. + * + * Calling `popRoute` will result in the last page on the router getting popped. + * You should not call this function when there is only one page on the router. + * + * @code{.js} + * Kirigami.PageRouter.navigateToRoute(["/home", "/login"]) + * // The PageRouter is navigated to /home/login + * Kirigami.PageRouter.popRoute() + * // The PageRouter is navigated to /home + * @endcode + */ + Q_INVOKABLE void popRoute(); + QVariant dataFor(QObject* object); + + static PageRouterAttached *qmlAttachedProperties(QObject *object); + +Q_SIGNALS: + void routesChanged(); + void initialRouteChanged(); + void cachePagesChanged(); +}; + +/** + * Attached object allowing children of a PageRouter to access its functions + * without requiring the children to have the parent PageRouter's id. + * + * @see PageRouter + */ +class PageRouterAttached : public QObject +{ + Q_OBJECT + + Q_PROPERTY(PageRouter *router READ router NOTIFY routerChanged) + /** + * The data for the page this item belongs to. Accessing this property + * outside of a PageRouter will result in undefined behavior. + */ + Q_PROPERTY(QVariant data READ data MEMBER m_data NOTIFY dataChanged) + +private: + explicit PageRouterAttached(QObject *parent = nullptr); + + QPointer m_router; + QVariant m_data; + + friend class PageRouter; + +public: + PageRouter* router() const { return m_router; }; + QVariant data() const; + /// @see PageRouter::navigateToRoute() + Q_INVOKABLE void navigateToRoute(QJSValue route) { m_router->navigateToRoute(route); }; + /// @see PageRouter::isNavigatedToRoute() + Q_INVOKABLE bool isNavigatedToRoute(QJSValue route) { return m_router->isNavigatedToRoute(route); }; + /// @see PageRouter::pushRoute() + Q_INVOKABLE void pushRoute(QJSValue route) { m_router->pushRoute(route); }; + /// @see PageRouter::popRoute() + Q_INVOKABLE void popRoute() { m_router->popRoute(); }; + +Q_SIGNALS: + void routerChanged(); + void dataChanged(); +}; + +QML_DECLARE_TYPEINFO(PageRouter, QML_HAS_ATTACHED_PROPERTIES) \ No newline at end of file diff --git a/src/pagerouter.cpp b/src/pagerouter.cpp new file mode 100644 --- /dev/null +++ b/src/pagerouter.cpp @@ -0,0 +1,252 @@ +/* + * SPDX-FileCopyrightText: 2020 Carson Black + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +#include +#include "pagerouter.h" + +ParsedRoute parseRoute(QJSValue value) +{ + if (value.isUndefined()) { + return ParsedRoute{QString(), QVariant()}; + } else if (value.isString()) { + return ParsedRoute{ + value.toString(), + QVariant() + }; + } else { + return ParsedRoute{ + value.property(QStringLiteral("route")).toString(), + value.property(QStringLiteral("data")).toVariant() + }; + } +} + +QList parseRoutes(QJSValue values) +{ + QList ret; + if (values.isArray()) { + for (auto route : values.toVariant().toList()) { + if (route.toString() != QString()) { + ret << ParsedRoute{ + route.toString(), + QVariant() + }; + } else if (route.canConvert()) { + auto map = route.value(); + ret << ParsedRoute{ + map.value(QStringLiteral("route")).toString(), + map.value(QStringLiteral("data")) + }; + } + } + } else { + ret << parseRoute(values); + } + return ret; +} + +PageRouter::PageRouter(QQuickItem *parent) : m_cachePages(false), QQuickItem(parent) +{ + connect(this, &PageRouter::cachePagesChanged, [=]() { + if (!m_cachePages) { + for (auto route : m_cachedRoutes) { + if (!m_currentRoutes.contains(route)) { + delete route.item; + } + } + m_cachedRoutes.clear(); + } + }); +} + +PageRouter::~PageRouter() {} + +void PageRouter::classBegin() +{ + +} + +void PageRouter::componentComplete() +{ + if (m_columnView == nullptr) { + QQmlComponent *component = new QQmlComponent(qmlEngine(this), this); + component->setData( + QByteArrayLiteral("import QtQuick 2.0; import org.kde.kirigami 2.7 as Kirigami; Kirigami.PageRow { anchors.fill: parent }"), + QUrl(QStringLiteral("pagerouter.cpp")) + ); + auto pagerow = qobject_cast(component->create(qmlContext(this))); + QQmlProperty::write(pagerow, QStringLiteral("parent"), QVariant::fromValue(this)); + + m_columnView = qobject_cast(QQmlProperty::read(pagerow, QStringLiteral("columnView")).value()); + connect(this, &PageRouter::initialRouteChanged, + [=]() { + m_columnView->clear(); + m_currentRoutes.clear(); + push(parseRoute(initialRoute())); + }); + } + m_columnView->clear(); + m_currentRoutes.clear(); + push(parseRoute(initialRoute())); +} + +bool PageRouter::routesContainsKey(const QString &key) +{ + return m_routes.hasProperty(key); +} + +QQmlComponent* PageRouter::routesValueForKey(const QString &key) +{ + return m_routes.property(key).toVariant().value(); +} + +void PageRouter::push(ParsedRoute route) +{ + if (!routesContainsKey(route.name)) { + qCritical() << "Route" << route.name << "not defined"; + return; + } + if (m_cachePages) { + for (auto cachedRoute : m_cachedRoutes) { + if (cachedRoute.name == route.name && cachedRoute.data == cachedRoute.data) { + m_currentRoutes << cachedRoute; + m_columnView->addItem(qobject_cast(cachedRoute.item)); + return; + } + } + } + auto context = qmlContext(this); + auto component = routesValueForKey(route.name); + auto createAndPush = [component, context, route, this]() { + // We use beginCreate and completeCreate to allow + // for a PageRouterAttached to find its parent + // on construction time. + auto item = component->beginCreate(context); + item->setParent(this); + auto clone = route; + clone.item = item; + m_currentRoutes << clone; + if (m_cachePages) { + m_cachedRoutes << clone; + } + component->completeCreate(); + m_columnView->addItem(qobject_cast(item)); + m_columnView->setCurrentIndex(m_currentRoutes.length()-1); + }; + + if (component->status() == QQmlComponent::Ready) { + createAndPush(); + } else if (component->status() == QQmlComponent::Loading) { + connect(component, &QQmlComponent::statusChanged, [=](QQmlComponent::Status status) { + // Loading can only go to Ready or Error. + if (status != QQmlComponent::Ready) { + qCritical() << "Failed to push route:" << component->errors(); + } + createAndPush(); + }); + } else { + qCritical() << "Failed to push route:" << component->errors(); + } +} + +QJSValue PageRouter::routes() const +{ + return m_routes; +} + +void PageRouter::setRoutes(QJSValue routes) +{ + m_routes = routes; +} + +QJSValue PageRouter::initialRoute() const +{ + return m_initialRoute; +} + +void PageRouter::setInitialRoute(QJSValue value) +{ + m_initialRoute = value; +} + +void PageRouter::navigateToRoute(QJSValue route) +{ + m_columnView->clear(); + m_currentRoutes.clear(); + for (auto route : parseRoutes(route)) { + push(route); + } +} + +bool PageRouter::isNavigatedToRoute(QJSValue route) +{ + auto parsed = parseRoutes(route); + if (parsed.length() > m_currentRoutes.length()) { + return false; + } + for (int i = 0; i < parsed.length(); i++) { + if (parsed[i].name != m_currentRoutes[i].name || parsed[i].data != m_currentRoutes[i].data) { + return false; + } + } + return true; +} + +void PageRouter::pushRoute(QJSValue route) +{ + push(parseRoute(route)); +} + +void PageRouter::popRoute() +{ + m_columnView->pop(qobject_cast(m_currentRoutes.last().item)); + m_currentRoutes.removeLast(); +} + +QVariant PageRouter::dataFor(QObject *object) +{ + auto pointer = object; + while (pointer != nullptr) { + for (auto route : m_currentRoutes) { + if (route.item == pointer) { + return route.data; + } + } + pointer = pointer->parent(); + } + return QVariant(); +} + +PageRouterAttached* PageRouter::qmlAttachedProperties(QObject *object) +{ + auto attached = new PageRouterAttached(object); + auto pointer = object; + // Climb the parent tree to find our parent PageRouter + while (pointer != nullptr) { + auto casted = qobject_cast(pointer); + if (casted != nullptr) { + attached->m_router = casted; + break; + } + pointer = pointer->parent(); + } + if (attached->m_router.isNull()) { + qCritical() << "PageRouterAttached could not find a parent PageRouter"; + } + return attached; +} + +QVariant PageRouterAttached::data() const +{ + if (m_router) { + return m_router->dataFor(parent()); + } else { + qCritical() << "PageRouterAttached does not have a parent PageRouter"; + return QVariant(); + } +} + +PageRouterAttached::PageRouterAttached(QObject *parent) : QObject(parent) {} \ No newline at end of file