diff --git a/src/pagerouter.cpp b/src/pagerouter.cpp index e49e45bf..d127bc58 100644 --- a/src/pagerouter.cpp +++ b/src/pagerouter.cpp @@ -1,405 +1,454 @@ /* * SPDX-FileCopyrightText: 2020 Carson Black * * SPDX-License-Identifier: LGPL-2.0-or-later */ #include #include #include #include #include +#include #include "pagerouter.h" ParsedRoute parseRoute(QJSValue value) { if (value.isUndefined()) { return ParsedRoute{QString(), QVariant(), false, nullptr}; } else if (value.isString()) { return ParsedRoute{ value.toString(), QVariant(), false, nullptr }; } else { return ParsedRoute{ value.property(QStringLiteral("route")).toString(), value.property(QStringLiteral("data")).toVariant(), false, nullptr }; } } QList parseRoutes(QJSValue values) { QList ret; if (values.isArray()) { for (auto route : values.toVariant().toList()) { if (route.toString() != QString()) { ret << ParsedRoute{ route.toString(), QVariant(), false, nullptr }; } else if (route.canConvert()) { auto map = route.value(); ret << ParsedRoute{ map.value(QStringLiteral("route")).toString(), map.value(QStringLiteral("data")), false, nullptr }; } } } else { ret << parseRoute(values); } return ret; } PageRouter::PageRouter(QQuickItem *parent) : QObject(parent) { connect(this, &PageRouter::pageStackChanged, [=]() { connect(m_pageStack, &ColumnView::itemRemoved, [=](QQuickItem *item) { QList toRemove; for (auto route : m_currentRoutes) { if (route.item == qobject_cast(item)) { if (!route.cache) { route.item->deleteLater(); } } } for (auto route : toRemove) { m_currentRoutes.removeAll(route); } }); connect(m_pageStack, &ColumnView::currentIndexChanged, this, &PageRouter::currentIndexChanged); }); } QQmlListProperty PageRouter::routes() { return QQmlListProperty(this, nullptr, appendRoute, routeCount, route, clearRoutes); } void PageRouter::appendRoute(QQmlListProperty* prop, PageRoute* route) { auto router = qobject_cast(prop->object); router->m_routes.append(route); } int PageRouter::routeCount(QQmlListProperty* prop) { auto router = qobject_cast(prop->object); return router->m_routes.length(); } PageRoute* PageRouter::route(QQmlListProperty* prop, int index) { auto router = qobject_cast(prop->object); return router->m_routes[index]; } void PageRouter::clearRoutes(QQmlListProperty* prop) { auto router = qobject_cast(prop->object); router->m_routes.clear(); } PageRouter::~PageRouter() {} void PageRouter::classBegin() { } void PageRouter::componentComplete() { if (m_pageStack == nullptr) { qCritical() << "PageRouter should be created with a ColumnView. Not doing so is undefined behaviour, and is likely to result in a crash upon further interaction."; } else { Q_EMIT pageStackChanged(); m_currentRoutes.clear(); push(parseRoute(initialRoute())); } } bool PageRouter::routesContainsKey(const QString &key) { for (auto route : m_routes) { if (route->name() == key) return true; } return false; } QQmlComponent* PageRouter::routesValueForKey(const QString &key) { for (auto route : m_routes) { if (route->name() == key) return route->component(); } return nullptr; } bool PageRouter::routesCacheForKey(const QString &key) { for (auto route : m_routes) { if (route->name() == key) return route->cache(); } return false; } void PageRouter::push(ParsedRoute route) { if (!routesContainsKey(route.name)) { qCritical() << "Route" << route.name << "not defined"; return; } if (routesCacheForKey(route.name)) { for (auto cachedRoute : m_cachedRoutes) { if (cachedRoute.name == route.name && cachedRoute.data == route.data) { m_currentRoutes << cachedRoute; m_pageStack->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; clone.cache = routesCacheForKey(route.name); m_currentRoutes << clone; if (routesCacheForKey(route.name)) { m_cachedRoutes << clone; } + auto attached = qobject_cast(qmlAttachedPropertiesObject(item, true)); + attached->m_router = this; component->completeCreate(); m_pageStack->addItem(qobject_cast(item)); m_pageStack->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::initialRoute() const { return m_initialRoute; } void PageRouter::setInitialRoute(QJSValue value) { m_initialRoute = value; } void PageRouter::navigateToRoute(QJSValue route) { auto incomingRoutes = parseRoutes(route); QList resolvedRoutes; if (incomingRoutes.length() <= m_currentRoutes.length()) { resolvedRoutes = m_currentRoutes.mid(0, incomingRoutes.length()); } else { resolvedRoutes = m_currentRoutes; resolvedRoutes.reserve(incomingRoutes.length()-m_currentRoutes.length()); } for (int i = 0; i < incomingRoutes.length(); i++) { auto current = resolvedRoutes.value(i); auto incoming = incomingRoutes.at(i); if (i >= resolvedRoutes.length()) { resolvedRoutes.append(incoming); } else if (current.name != incoming.name || current.data != incoming.data) { resolvedRoutes.replace(i, incoming); } } for (auto route : m_currentRoutes) { if (!resolvedRoutes.contains(route)) { if (!route.cache) { route.item->deleteLater(); } } } m_pageStack->clear(); m_currentRoutes.clear(); for (auto toPush : resolvedRoutes) { push(toPush); } } void PageRouter::bringToView(QJSValue route) { if (route.isNumber()) { auto index = route.toNumber(); m_pageStack->setCurrentIndex(index); } else { auto parsed = parseRoute(route); auto index = 0; for (auto currentRoute : m_currentRoutes) { if (currentRoute.name == parsed.name && currentRoute.data == parsed.data) { m_pageStack->setCurrentIndex(index); return; } index++; } qWarning() << "Route" << parsed.name << "with data" << parsed.data << "is not on the current stack of routes."; } } bool PageRouter::routeActive(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) { return false; } if (parsed[i].data.isValid()) { if (parsed[i].data != m_currentRoutes[i].data) { return false; } } } return true; } void PageRouter::pushRoute(QJSValue route) { push(parseRoute(route)); } void PageRouter::popRoute() { m_pageStack->pop(qobject_cast(m_currentRoutes.last().item)); if (!m_currentRoutes.last().cache) { m_currentRoutes.last().item->deleteLater(); } 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(); } bool PageRouter::isActive(QObject *object) { auto pointer = object; while (pointer != nullptr) { auto index = 0; for (auto route : m_currentRoutes) { if (route.item == pointer) { return m_pageStack->currentIndex() == index; } index++; } pointer = pointer->parent(); } qWarning() << "Object" << object << "not in current routes"; return false; } PageRouterAttached* PageRouter::qmlAttachedProperties(QObject *object) { auto attached = new PageRouterAttached(object); - auto asItem = qobject_cast(object); - auto seekParent = [](QObject *seek) -> PageRouter* { - auto pointer = seek; - while (pointer != nullptr) { - auto casted = qobject_cast(pointer); - if (casted != nullptr) { - return casted; - } - pointer = pointer->parent(); - } - return nullptr; - }; - if (asItem) { - while (asItem != nullptr) { - auto parent = seekParent(asItem); - if (parent != nullptr) { - attached->m_router = parent; - connect(parent, &PageRouter::currentIndexChanged, attached, &PageRouterAttached::isCurrentChanged); - break; - } - asItem = asItem->parentItem(); + return attached; +} + +void PageRouterAttached::findParent() +{ + QQuickItem *parent = qobject_cast(this->parent()); + while (parent != nullptr) { + auto attached = qobject_cast(qmlAttachedPropertiesObject(parent, false)); + if (attached != nullptr && attached->m_router != nullptr) { + m_router = attached->m_router; + Q_EMIT routerChanged(); + Q_EMIT dataChanged(); + Q_EMIT isCurrentChanged(); + break; } + parent = parent->parentItem(); + } +} + +void PageRouterAttached::navigateToRoute(QJSValue route) +{ + if (m_router) { + m_router->navigateToRoute(route); } else { - auto parent = seekParent(object); - if (parent != nullptr) { - attached->m_router = parent; - connect(parent, &PageRouter::currentIndexChanged, attached, &PageRouterAttached::isCurrentChanged); - } + qCritical() << "PageRouterAttached does not have a parent PageRouter"; + return; } - if (attached->m_router.isNull()) { - qCritical() << "PageRouterAttached could not find a parent PageRouter"; +} + +bool PageRouterAttached::routeActive(QJSValue route) +{ + if (m_router) { + return m_router->routeActive(route); + } else { + qCritical() << "PageRouterAttached does not have a parent PageRouter"; + return false; + } +} + +void PageRouterAttached::pushRoute(QJSValue route) +{ + if (m_router) { + m_router->pushRoute(route); + } else { + qCritical() << "PageRouterAttached does not have a parent PageRouter"; + return; + } +} + +void PageRouterAttached::popRoute() +{ + if (m_router) { + m_router->popRoute(); + } else { + qCritical() << "PageRouterAttached does not have a parent PageRouter"; + return; + } +} + +void PageRouterAttached::bringToView(QJSValue route) +{ + if (m_router) { + m_router->bringToView(route); + } else { + qCritical() << "PageRouterAttached does not have a parent PageRouter"; + return; } - 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(); } } bool PageRouterAttached::isCurrent() const { if (m_router) { return m_router->isActive(parent()); } else { qCritical() << "PageRouterAttached does not have a parent PageRouter"; return false; } } QJSValue PageRouter::currentRoutes() const { auto engine = qjsEngine(this); auto ret = engine->newArray(m_currentRoutes.length()); for (int i = 0; i < m_currentRoutes.length(); ++i) { auto object = engine->newObject(); object.setProperty(QStringLiteral("route"), m_currentRoutes[i].name); object.setProperty(QStringLiteral("data"), engine->toScriptValue(m_currentRoutes[i].data)); ret.setProperty(i, object); } return ret; } -PageRouterAttached::PageRouterAttached(QObject *parent) : QObject(parent) {} \ No newline at end of file +PageRouterAttached::PageRouterAttached(QObject *parent) : QObject(parent) +{ + findParent(); + auto item = qobject_cast(parent); + if (item != nullptr) { + connect(item, &QQuickItem::windowChanged, this, [this]() { + findParent(); + }); + connect(item, &QQuickItem::parentChanged, this, [this]() { + findParent(); + }); + } +} \ No newline at end of file diff --git a/src/pagerouter.h b/src/pagerouter.h index ffd03cba..ccb721dd 100644 --- a/src/pagerouter.h +++ b/src/pagerouter.h @@ -1,465 +1,467 @@ /* * 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; bool cache; QObject* item; bool operator==(const ParsedRoute& rhs) { return name == rhs.name && data == rhs.data && item == rhs.item && cache == rhs.cache; } bool operator!=(const ParsedRoute& rhs) { return name != rhs.name && data != rhs.data && item != rhs.item && cache != rhs.cache; } }; /** * Item representing a route the PageRouter can navigate to. * * @include PageRoute.qml * * @see PageRouter */ class PageRoute : public QObject { Q_OBJECT /** * @brief The name of this route. * * This name should be unique per PageRoute in a PageRouter. * When two PageRoutes have the same name, the one listed first * in the PageRouter will be used. */ Q_PROPERTY(QString name MEMBER m_name READ name) /** * @brief The page component of this route. * * This should be an instance of Component with a Kirigami::Page inside * of it. */ Q_PROPERTY(QQmlComponent* component MEMBER m_component READ component) /** * @brief Whether pages generated by this route should be cached or not. * * This should be an instance of Component with a Kirigami::Page inside * of it. * * This will not work: * * @include PageRouterCachePagesDont.qml * * This will work: * * @include PageRouterCachePagesDo.qml * */ Q_PROPERTY(bool cache MEMBER m_cache READ cache) Q_CLASSINFO("DefaultProperty", "component") private: QString m_name; QQmlComponent* m_component; bool m_cache = false; public: QQmlComponent* component() { return m_component; }; QString name() { return m_name; }; bool cache() { return m_cache; }; }; class PageRouterAttached; /** * An item managing pages and data of a ColumnView using named routes. * *

* * ## Using a PageRouter * * Applications typically manage their contents via elements called "pages" or "screens." * In Kirigami, these are called @link org::kde::kirigami::Page Pages @endlink and are * arranged in @link PageRoute routes @endlink using a PageRouter to manage them. The PageRouter * manages a stack of @link org::kde::kirigami::Page Pages @endlink created from a pool of potential * @link PageRoute PageRoutes @endlink. * * Unlike most traditional stacks, a PageRouter provides functions for random access to its pages * with navigateToRoute and routeActive. * * When your user interface fits the stack paradigm and is likely to use random access navigation, * using the PageRouter is appropriate. For simpler navigation, it is more appropriate to avoid * the overhead of a PageRouter by using a @link org::kde::kirigami::PageRow PageRow @endlink * instead. * *

* * ## Navigation Model * * A PageRouter draws from a pool of @link PageRoute PageRoutes @endlink in order to construct * its stack. * * @image html PageRouterModel.svg width=50% * *

* * You can push pages onto this stack... * * @image html PageRouterPush.svg width=50% * * ...or pop them off... * * @image html PageRouterPop.svg width=50% * * ...or navigate to an arbitrary collection of pages. * * @image html PageRouterNavigate.svg width=50% * *

* * Components are able to query the PageRouter about the currently active routes * on the stack. This is useful for e.g. a card indicating that the page it takes * the user to is currently active. * *

* * ## Example * * @include PageRouter.qml * * @see PageRouterAttached * @see PageRoute */ class PageRouter : public QObject, public QQmlParserStatus { Q_OBJECT Q_INTERFACES(QQmlParserStatus) /** * @brief The named routes a PageRouter can navigate to. * * @include PageRouterRoutes.qml */ Q_PROPERTY(QQmlListProperty routes READ routes) Q_CLASSINFO("DefaultProperty", "routes") /** * @brief The initial route. * * `initialRoute` is 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. * * @include PageRouterInitialRoute.qml */ Q_PROPERTY(QJSValue initialRoute READ initialRoute WRITE setInitialRoute NOTIFY initialRouteChanged) /** * @brief The ColumnView being puppeted by the PageRouter. * * All PageRouters should be created with a ColumnView, and creating one without * a ColumnView is undefined behaviour. * * @warning You should **not** directly interact with a ColumnView being puppeted * by a PageRouter. Instead, use a PageRouter's functions to manipulate the * ColumnView. * * @include PageRouterColumnView.qml */ Q_PROPERTY(ColumnView* pageStack MEMBER m_pageStack NOTIFY pageStackChanged) private: /** * @brief The routes the PageRouter is aware of. * * Generally, this should not be mutated from C++, only read. */ QList m_routes; /** * @brief The PageRouter being puppeted. * * m_pageRow is the ColumnView this PageRouter puppets. */ ColumnView* m_pageStack = 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; /** * @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); /** * @brief Helper function to access the cache status of a key for m_routes. * * The return value will be false if @p key does not exist in * m_routes. */ bool routesCacheForKey(const QString &key); static void appendRoute(QQmlListProperty* list, PageRoute*); static int routeCount(QQmlListProperty* list); static PageRoute* route(QQmlListProperty* list, int); static void clearRoutes(QQmlListProperty* list); QVariant dataFor(QObject* object); bool isActive(QObject* object); friend class PageRouterAttached; protected: void classBegin() override; void componentComplete() override; public: PageRouter(QQuickItem *parent = nullptr); ~PageRouter(); QQmlListProperty 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. * * `routeActive` will return true if the given route * is on the stack. * * @param route The given route to check for. * * `routeActive` returns true for partial routes like * the following: * * @code{.js} * Kirigami.PageRouter.navigateToRoute(["/home", "/login", "/google"]) * Kirigami.PageRouter.routeActive(["/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.routeActive(["/login", "/google"]) // returns false * @endcode */ Q_INVOKABLE bool routeActive(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(); /** * @brief Shifts keyboard focus and view to a given index on the PageRouter's stack. * * @param view The view to bring to focus. If this is an integer index, the PageRouter will * navigate to the given index. If it's a route specifier, the PageRouter will navigate * to the first route matching it. * * Navigating to route by index: * @code{.js} * Kirigami.PageRouter.navigateToRoute(["/home", "/browse", "/apps", "/login"]) * Kirigami.PageRouter.bringToView(1) * @endcode * * Navigating to route by name: * @code{.js} * Kirigami.PageRouter.navigateToRoute(["/home", "/browse", "/apps", "/login"]) * Kirigami.PageRouter.bringToView("/browse") * @endcode * * Navigating to route by data: * @code{.js} * Kirigami.PageRouter.navigateToRoute([{"route": "/page", "data": "red"}, * {"route": "/page", "data": "blue"}, * {"route": "/page", "data": "green"}, * {"route": "/page", "data": "yellow"}]) * Kirigami.PageRouter.bringToView({"route": "/page", "data": "blue"}) * @endcode */ Q_INVOKABLE void bringToView(QJSValue route); /** * @brief Returns a QJSValue corresponding to the current pages on the stack. * * The returned value is in the same form as the input to navigateToRoute. */ Q_INVOKABLE QJSValue currentRoutes() const; static PageRouterAttached *qmlAttachedProperties(QObject *object); Q_SIGNALS: void routesChanged(); void initialRouteChanged(); void pageStackChanged(); void currentIndexChanged(); }; /** * 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) /** * Whether the page this item belongs to is the current index of the ColumnView. * Accessing this property outside of a PageRouter will result in undefined behaviour. */ Q_PROPERTY(bool isCurrent READ isCurrent NOTIFY isCurrentChanged) private: explicit PageRouterAttached(QObject *parent = nullptr); QPointer m_router; QVariant m_data; + void findParent(); + friend class PageRouter; public: PageRouter* router() const { return m_router; }; QVariant data() const; bool isCurrent() const; /// @see PageRouter::navigateToRoute() - Q_INVOKABLE void navigateToRoute(QJSValue route) { m_router->navigateToRoute(route); }; + Q_INVOKABLE void navigateToRoute(QJSValue route); /// @see PageRouter::routeActive() - Q_INVOKABLE bool routeActive(QJSValue route) { return m_router->routeActive(route); }; + Q_INVOKABLE bool routeActive(QJSValue route); /// @see PageRouter::pushRoute() - Q_INVOKABLE void pushRoute(QJSValue route) { m_router->pushRoute(route); }; + Q_INVOKABLE void pushRoute(QJSValue route); /// @see PageRouter::popRoute() - Q_INVOKABLE void popRoute() { m_router->popRoute(); }; + Q_INVOKABLE void popRoute(); // @see PageRouter::bringToView() - Q_INVOKABLE void bringToView(QJSValue route) { m_router->bringToView(route); }; + Q_INVOKABLE void bringToView(QJSValue route); Q_SIGNALS: void routerChanged(); void dataChanged(); void isCurrentChanged(); }; QML_DECLARE_TYPEINFO(PageRouter, QML_HAS_ATTACHED_PROPERTIES)