diff --git a/.gitignore b/.gitignore index 3330ae38..8cc84158 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,23 @@ # Ignore the following files *~ *.[oa] *.diff *.kate-swp *.kdev4 .kdev_include_paths *.kdevelop.pcs *.moc *.moc.cpp *.orig *.user .*.swp .swp.* Doxyfile Makefile avail random_seed /build*/ CMakeLists.txt.user* *.unc-backup* .cmake/ +.vscode/ diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 5a681a53..68da75c4 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -1,35 +1,36 @@ 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 tst_pagerouter.qml tst_routerwindow.qml pagepool/tst_pagepool.qml + pagepool/tst_layers.qml ) diff --git a/autotests/pagepool/tst_layers.qml b/autotests/pagepool/tst_layers.qml new file mode 100644 index 00000000..3b97ee07 --- /dev/null +++ b/autotests/pagepool/tst_layers.qml @@ -0,0 +1,307 @@ +/* + * SPDX-FileCopyrightText: 2020 Mason McParlane + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +import QtQuick 2.7 +import QtQuick.Controls 2.7 +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: "PagePoolWithLayers" + when: windowShown + + function initTestCase() { + mainWindow.show() + } + + function cleanupTestCase() { + mainWindow.close() + } + + Kirigami.ApplicationWindow { + id: mainWindow + width: 480 + height: 360 + } + + Kirigami.PagePool { + id: pool + } + + SignalSpy { + id: stackSpy + target: mainWindow.pageStack + signalName: "onCurrentItemChanged" + } + + SignalSpy { + id: layerSpy + target: mainWindow.pageStack.layers + signalName: "onCurrentItemChanged" + } + + + function init() { + pool.clear() + mainWindow.pageStack.layers.clear() + compare(mainWindow.pageStack.layers.depth, 1) + mainWindow.pageStack.clear() + + for (var spy of [stackSpy, layerSpy, checkSpy_A, checkSpy_B, checkSpy_C, checkSpy_D, checkSpy_E]) { + spy.clear() + } + } + + ActionGroup { + id: group + exclusive: false + + Kirigami.PagePoolAction { + id: stackPageA + objectName: "stackPageA" + pagePool: pool + pageStack: mainWindow.pageStack + page: "TestPage.qml?page=A" + initialProperties: { return {title: "A", objectName: "Page A" } } + } + + Kirigami.PagePoolAction { + id: stackPageB + objectName: "stackPageB" + pagePool: pool + pageStack: mainWindow.pageStack + page: "TestPage.qml?page=B" + initialProperties: { return {title: "B", objectName: "Page B" } } + } + + Kirigami.PagePoolAction { + id: layerPageC + objectName: "layerPageC" + pagePool: pool + pageStack: mainWindow.pageStack + useLayers: true + page: "TestPage.qml?page=C" + initialProperties: { return {title: "C", objectName: "Page C" } } + } + + Kirigami.PagePoolAction { + id: layerPageD + objectName: "layerPageD" + pagePool: pool + pageStack: mainWindow.pageStack + useLayers: true + page: "TestPage.qml?page=D" + initialProperties: { return {title: "D", objectName: "Page D" } } + } + + Kirigami.PagePoolAction { + id: stackPageE + objectName: "stackPageE" + pagePool: pool + pageStack: mainWindow.pageStack + page: "TestPage.qml?page=E" + initialProperties: { return {title: "E", objectName: "Page E" } } + } + } + + function tapBack () { + mouseClick(mainWindow, 10, 10) + } + + function test_pushLayerBackButtonPushAgain() { + var stack = mainWindow.pageStack + var layers = stack.layers + + function pushA() { + stackPageA.trigger() + compare(stack.currentItem, pool.lastLoadedItem) + } + + function pushC () { + layerPageC.trigger() + compare(layers.currentItem, pool.lastLoadedItem) + } + + function pushD () { + layerPageD.trigger() + compare(layers.currentItem, pool.lastLoadedItem) + } + + compare(stackSpy.count, 0) + pushA() + compare(stackSpy.count, 1) + compare(layerSpy.count, 0) + pushC() + compare(layerSpy.count, 1) + pushD() + compare(layerSpy.count, 2) + compare(stackSpy.count, 1) + tapBack() + compare(layerSpy.count, 3) + pushD() + compare(layerSpy.count, 4) + } + + SignalSpy { + id: checkSpy_A + target: stackPageA + signalName: "onCheckedChanged" + } + + SignalSpy { + id: checkSpy_B + target: stackPageB + signalName: "onCheckedChanged" + } + + SignalSpy { + id: checkSpy_C + target: layerPageC + signalName: "onCheckedChanged" + } + + SignalSpy { + id: checkSpy_D + target: layerPageD + signalName: "onCheckedChanged" + } + + SignalSpy { + id: checkSpy_E + target: stackPageE + signalName: "onCheckedChanged" + } + + function dump_layers(msg = "") { + for (var i = 0; i < mainWindow.pageStack.layers.depth; ++i) { + console.debug(`${msg} ${i}: ${mainWindow.pageStack.layers.get(i)}`) + } + } + + function test_checked() { + var stack = mainWindow.pageStack + var layers = stack.layers + + function testCheck(expected = {}) { + let defaults = { + a: false, b: false, c: false, d: false, e: false + } + let actual = Object.assign({}, defaults, expected) + let pages = {a: stackPageA, b: stackPageB, c: layerPageC, d: layerPageD, e: stackPageE} + + for (const prop in actual) { + compare(pages[prop].checked, actual[prop], + `${pages[prop]} should ${actual[prop] ? 'be checked' : 'not be checked'}`) + } + } + + testCheck() + + compare(stackSpy.count, 0) + compare(layerSpy.count, 0) + compare(checkSpy_A.count, 0) + compare(checkSpy_B.count, 0) + compare(checkSpy_C.count, 0) + compare(checkSpy_D.count, 0) + compare(checkSpy_E.count, 0) + + stackPageA.trigger() + compare(checkSpy_A.count, 1) + testCheck({a:true}) + compare(stack.currentItem, stackPageA.pageItem()) + + stackPageB.trigger() + compare(checkSpy_A.count, 2) + compare(checkSpy_B.count, 3) + testCheck({b:true}) + compare(stack.currentItem, stackPageB.pageItem()) + + layerPageC.trigger() + testCheck({b:true, c:true}) + compare(checkSpy_C.count, 1) + compare(stack.currentItem, stackPageB.pageItem()) + compare(layers.currentItem, layerPageC.pageItem()) + compare(layerPageC.layerContainsPage(), true) + + layerPageD.trigger() + compare(stack.currentItem, stackPageB.pageItem()) + compare(layers.currentItem, layerPageD.pageItem()) + testCheck({b:true, c:true, d:true}) + + stackPageE.basePage = stack.currentItem + stackPageE.trigger() + testCheck({b:true, e:true}) + compare(stack.currentItem, stackPageE.pageItem()) + verify(!(layers.currentItem instanceof Page), + `Current item ${layers.currentItem} is a page but all pages should be popped`) + + stackPageA.trigger() + testCheck({a:true}) + compare(stack.currentItem, stackPageA.pageItem()) + verify(!(layers.currentItem instanceof Page), + `Current item ${layers.currentItem} is a page but all pages should be popped`) + + compare(checkSpy_A.count, 5) + compare(checkSpy_B.count, 4) + compare(checkSpy_C.count, 2) + compare(checkSpy_D.count, 2) + compare(checkSpy_E.count, 2) + } + + function test_push_A_C_D_A_popsLayers() { + var stack = mainWindow.pageStack + var layers = stack.layers + + stackPageA.trigger() + compare(stack.currentItem, stackPageA.pageItem()) + + layerPageC.trigger() + compare(layers.currentItem, layerPageC.pageItem()) + + layerPageD.trigger() + compare(layers.currentItem, layerPageD.pageItem()) + + stackPageA.trigger() + compare(stack.currentItem, stackPageA.pageItem()) + verify(!(layers.currentItem instanceof Page), + `Current item ${layers.currentItem} is a page but all pages should be popped`) + } + + function test_push_A_C_D_back_back_C_back_C() { + var stack = mainWindow.pageStack + var layers = stack.layers + + stackPageA.trigger() + layerPageC.trigger() + layerPageD.trigger() + tapBack() + tapBack() + layerPageC.trigger() + tapBack() + layerPageC.trigger() + compare(layers.currentItem, layerPageC.pageItem()) + } + + function test_exclusive_group() { + var stack = mainWindow.pageStack + var layers = stack.layers + + group.exclusive = true + stackPageA.trigger() + compare(stackPageA.checked, true) + compare(layerPageC.checked, false) + layerPageC.trigger() + compare(stackPageA.checked, false) + compare(layerPageC.checked, true) + tapBack() + compare(stackPageA.checked, true) + compare(layerPageC.checked, false) + } +} diff --git a/src/controls/PagePoolAction.qml b/src/controls/PagePoolAction.qml index 1f58da8b..db74db62 100644 --- a/src/controls/PagePoolAction.qml +++ b/src/controls/PagePoolAction.qml @@ -1,107 +1,208 @@ /* * SPDX-FileCopyrightText: 2016 Marco Martin * * SPDX-License-Identifier: LGPL-2.0-or-later */ import QtQuick 2.7 +import QtQml 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 + * 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 + /** useLayers: bool + * @since 5.70 + * @since org.kde.kirigami 2.12 + * When true the PagePoolAction will use the layers property of the pageStack. + * This is intended for use with PageRow layers to allow PagePoolActions to + * push context-specific pages onto the layers stack. + */ + property bool useLayers: false + + /** + * Retrieve the page item held in the PagePool or null if it has not been loaded yet. + */ + function pageItem() { + return pagePool.pageForUrl(page) + } + + /** + * Return true if the page has been loaded and placed on pageStack.layers + * and useLayers is true, otherwise returns null. + */ + function layerContainsPage() { + if (!useLayers || !pageStack.hasOwnProperty("layers")) return false + + var found = pageStack.layers.find((item, index) => { + return item === pagePool.pageForUrl(page) + }) + return found ? true: false + } + + /** + * Return true if the page has been loaded and placed on the pageStack, + * otherwise returns null. + */ + function stackContainsPage() { + if (useLayers) return false + return pageStack.columnView.containsItem(pagePool.pageForUrl(page)) + } + + checkable: true + onTriggered: { if (page.length == 0 || !pagePool || !pageStack) { return; } - if (initialProperties && typeof(initialProperties) !== "object") { - console.warn("initialProperties must be of type object"); - return; + // User intends to "go back" to this layer. + if (layerContainsPage() && pageItem() !== pageStack.layers.currentItem) { + pageStack.layers.replace(pageItem(), pageItem()) // force pop above + return } - if (pagePool.resolvedUrl(page) == pagePool.lastLoadedUrl) { + // User intends to "go back" to this page. + if (stackContainsPage()) { + if (pageStack.hasOwnProperty("layers")) { + pageStack.layers.clear() + } + } + + let pageStack_ = useLayers ? pageStack.layers : pageStack + + if (initialProperties && typeof(initialProperties) !== "object") { + console.warn("initialProperties must be of type object"); return; } - if (!pageStack.hasOwnProperty("pop") || typeof pageStack.pop !== "function" || !pageStack.hasOwnProperty("push") || typeof pageStack.push !== "function") { + 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_.pop(basePage); + + } else if (!useLayers) { + pageStack_.clear(); } - pageStack.push(initialProperties ? + pageStack_.push(initialProperties ? pagePool.loadPageWithProperties(page, initialProperties) : pagePool.loadPage(page)); } else { var callback = function(item) { if (basePage) { - pageStack.pop(basePage); - } else { - pageStack.clear(); + pageStack_.pop(basePage); + + } else if (!useLayers) { + pageStack_.clear(); } - pageStack.push(item); + pageStack_.push(item); }; if (initialProperties) { pagePool.loadPage(page, initialProperties, callback); } else { pagePool.loadPage(page, callback); } } } + + // Exposing this as a property is required as Action does not have a default property + property QtObject _private: QtObject { + id: _private + + function setChecked(checked) { + root.checked = checked + } + + function clearLayers() { + pageStack.layers.clear() + } + + property list connections: [ + Connections { + target: pageStack + + onCurrentItemChanged: { + if (root.useLayers) { + if (root.layerContainsPage()) { + _private.clearLayers() + } + if (root.checkable) + _private.setChecked(false); + + } else { + if (root.checkable) + _private.setChecked(root.stackContainsPage()); + } + } + }, + Connections { + enabled: pageStack.hasOwnProperty("layers") + target: pageStack.layers + + onCurrentItemChanged: { + if (root.useLayers && root.checkable) { + _private.setChecked(root.layerContainsPage()); + + } else { + if (pageStack.layers.depth == 1 && root.stackContainsPage()) { + _private.setChecked(true) + } + } + } + } + ] + } } diff --git a/src/controls/PageRow.qml b/src/controls/PageRow.qml index 63b0ee19..228a589a 100644 --- a/src/controls/PageRow.qml +++ b/src/controls/PageRow.qml @@ -1,624 +1,624 @@ /* * SPDX-FileCopyrightText: 2016 Marco Martin * * SPDX-License-Identifier: LGPL-2.0-or-later */ import QtQuick 2.5 import QtQuick.Layouts 1.2 import QtQml.Models 2.2 import QtQuick.Templates 2.0 as T import QtQuick.Controls 2.0 as QQC2 import org.kde.kirigami 2.7 import "private/globaltoolbar" as GlobalToolBar import "templates" as KT /** * PageRow implements a row-based navigation model, which can be used * with a set of interlinked information pages. Items are pushed in the * back of the row and the view scrolls until that row is visualized. * A PageRow can show a single page or a multiple set of columns, depending * on the window width: on a phone a single column should be fullscreen, * while on a tablet or a desktop more than one column should be visible. * @inherit QtQuick.Templates.Control */ T.Control { id: root //BEGIN PROPERTIES /** * This property holds the number of items currently pushed onto the view */ property alias depth: columnView.count /** * The last Page in the Row */ readonly property Item lastItem: columnView.contentChildren.length > 0 ? columnView.contentChildren[columnView.contentChildren.length - 1] : null /** * The currently visible Item */ property alias currentItem: columnView.currentItem /** * the index of the currently visible Item */ property alias currentIndex: columnView.currentIndex /** * The initial item when this PageRow is created */ property variant initialPage /** * The main ColumnView of this Row */ contentItem: columnView /** * columnView: Kirigami::ColumnView * * The ColumnView that this PageRow owns. * Generally, you shouldn't need to change * the value of this. * * @since 2.12 */ property alias columnView: columnView /** * items: list * All the items that are present in the PageRow * @since 2.6 */ property alias items: columnView.contentChildren; /** * visibleItems: list * All pages which are visible in the PageRow, excluding those which are scrolled away * @since 2.6 */ property alias visibleItems: columnView.visibleItems /** * firstVisibleItem: Item * The first at least partially visible page in the PageRow, pages before that one will be out of the viewport * @since 2.6 */ property alias firstVisibleItem: columnView.firstVisibleItem /** * lastVisibleItem: Item * The last at least partially visible page in the PageRow, pages after that one will be out of the viewport * @since 2.6 */ property alias lastVisibleItem: columnView.lastVisibleItem /** * The default width for a column * default is wide enough for 30 grid units. * Pages can override it with their Layout.fillWidth, * implicitWidth Layout.minimumWidth etc. */ property int defaultColumnWidth: Units.gridUnit * 20 /** * interactive: bool * If true it will be possible to go back/forward by dragging the * content themselves with a gesture. * Otherwise the only way to go back will be programmatically * default: true */ property alias interactive: columnView.interactive /** * wideMode: bool * If true, the PageRow is wide enough that willshow more than one column at once * @since 5.37 */ readonly property bool wideMode: root.width >= root.defaultColumnWidth*2 && depth >= 2 /** * separatorVisible: bool * True if the separator between pages should be visible * default: true * @since 5.38 */ property alias separatorVisible: columnView.separatorVisible /** * globalToolBar: grouped property * Controls the appearance of an optional global toolbar for the whole PageRow. * It's a grouped property comprised of the following properties: * * style (Kirigami.ApplicationHeaderStyle): can have the following values: * * Auto: depending on application formfactor, it can behave automatically like other values, such as a Breadcrumb on mobile and ToolBar on desktop * * Breadcrumb: it will show a breadcrumb of all the page titles in the stack, for easy navigation * * Titles: each page will only have its own tile on top * * TabBar: the global toolbar will look like a TabBar to select the pages * * ToolBar: each page will have the title on top together buttons and menus to represent all of the page actions: not available on Mobile systems. * * None: no global toolbar will be shown * * * actualStyle: this will represent the actual style of the toolbar: it can be different from style in the case style is Auto * * showNavigationButtons: OR flags combination of ApplicationHeaderStyle.ShowBackButton and ApplicationHeaderStyle.ShowForwardButton * * toolbarActionAlignment: How to horizontally align the actions when using the ToolBar style. Note that anything but Qt.AlignRight will cause the title to be hidden (default: Qt.AlignRight) * * minimumHeight (int): minimum height of the header, which will be resized when scrolling, only in Mobile mode (default: preferredHeight, sliding but no scaling) * * preferredHeight (int): the height the toolbar will usually have * * maximumHeight (int): the height the toolbar will have in mobile mode when the app is in reachable mode (default: preferredHeight * 1.5) * * leftReservedSpace (int, readonly): how many pixels are reserved at the left of the page toolbar (for navigation buttons or drawer handle) * * rightReservedSpace (int, readonly): how many pixels are reserved at the right of the page toolbar (drawer handle) * * @since 5.48 */ readonly property alias globalToolBar: globalToolBar implicitWidth: contentItem.implicitWidth + leftPadding + rightPadding implicitHeight: contentItem.implicitHeight + topPadding + bottomPadding //END PROPERTIES //BEGIN FUNCTIONS /** * Pushes a page on the stack. * The page can be defined as a component, item or string. * If an item is used then the page will get re-parented. * If a string is used then it is interpreted as a url that is used to load a page * component. * The last pushed page will become the current item. * * @param page The page can also be given as an array of pages. * In this case all those pages will * be pushed onto the stack. The items in the stack can be components, items or * strings just like for single pages. * Additionally an object can be used, which specifies a page and an optional * properties property. * This can be used to push multiple pages while still giving each of * them properties. * When an array is used the transition animation will only be to the last page. * * @param properties The properties argument is optional and allows defining a * map of properties to set on the page. If page is actually an array of pages, properties should also be an array of key/value maps * @return The new created page (or the last one if it was an array) */ function push(page, properties) { var item = insertPage(depth, page, properties); currentIndex = depth - 1; return item; } /** * Inserts a new page or a list of new at an arbitrary position * The page can be defined as a component, item or string. * If an item is used then the page will get re-parented. * If a string is used then it is interpreted as a url that is used to load a page * component. * The current Page will not be changed, currentIndex will be adjusted * accordingly if needed to keep the same current page. * * @param page The page can also be given as an array of pages. * In this case all those pages will * be pushed onto the stack. The items in the stack can be components, items or * strings just like for single pages. * Additionally an object can be used, which specifies a page and an optional * properties property. * This can be used to push multiple pages while still giving each of * them properties. * When an array is used the transition animation will only be to the last page. * * @param properties The properties argument is optional and allows defining a * map of properties to set on the page. If page is actually an array of pages, properties should also be an array of key/value maps * @return The new created page (or the last one if it was an array) * @since 2.7 */ function insertPage(position, page, properties) { if (!page) { return null } //don't push again things already there if (page.createObject === undefined && typeof page != "string" && columnView.containsItem(page)) { print("The item " + page + " is already in the PageRow"); return null; } position = Math.max(0, Math.min(depth, position)); columnView.pop(columnView.currentItem); // figure out if more than one page is being pushed var pages; var propsArray = []; if (page instanceof Array) { pages = page; page = pages.pop(); //compatibility with pre-qqc1 api, can probably be removed if (page.createObject === undefined && page.parent === undefined && typeof page != "string") { properties = properties || page.properties; page = page.page; } } if (properties instanceof Array) { propsArray = properties; properties = propsArray.pop(); } else { propsArray = [properties]; } // push any extra defined pages onto the stack if (pages) { var i; for (i = 0; i < pages.length; i++) { var tPage = pages[i]; var tProps = propsArray[i]; //compatibility with pre-qqc1 api, can probably be removed if (tPage.createObject === undefined && tPage.parent === undefined && typeof tPage != "string") { if (columnView.containsItem(tPage)) { print("The item " + page + " is already in the PageRow"); continue; } tProps = tPage.properties; tPage = tPage.page; } var pageItem = pagesLogic.initAndInsertPage(position, tPage, tProps); ++position; } } // initialize the page var pageItem = pagesLogic.initAndInsertPage(position, page, properties); pagePushed(pageItem); return pageItem; } /** * Move the page at position fromPos to the new position toPos * If needed, currentIndex will be adjusted * in order to keep the same current page. * @since 2.7 */ function movePage(fromPos, toPos) { columnView.moveItem(fromPos, toPos); } /** * Remove the given page * @param page The page can be given both as integer position or by reference * @return The page that has just been removed * @since 2.7 */ function removePage(page) { if (depth == 0) { return null; } return columnView.removeItem(page); } /** * Pops a page off the stack. * @param page If page is specified then the stack is unwound to that page, * to unwind to the first page specify * page as null. * @return The page instance that was popped off the stack. */ function pop(page) { if (depth == 0) { return null; } return columnView.pop(page); } /** * Emitted when a page has been inserted anywhere * @param position where the page has been inserted * @param page the new page * @since 2.7 */ signal pageInserted(int position, Item page) /** * Emitted when a page has been pushed to the bottom * @param page the new page * @since 2.5 */ signal pagePushed(Item page) /** * Emitted when a page has been removed from the row. * @param page the page that has been removed: at this point it's still valid, * but may be auto deleted soon. * @since 2.5 */ signal pageRemoved(Item page) /** * Replaces a page on the stack. * @param page The page can also be given as an array of pages. * In this case all those pages will * be pushed onto the stack. The items in the stack can be components, items or * strings just like for single pages. * the current page and all pagest after it in the stack will be removed. * Additionally an object can be used, which specifies a page and an optional * properties property. * This can be used to push multiple pages while still giving each of * them properties. * When an array is used the transition animation will only be to the last page. * @param properties The properties argument is optional and allows defining a * map of properties to set on the page. * @see push() for details. */ function replace(page, properties) { if (currentIndex >= 1) { pop(columnView.contentChildren[currentIndex-1]); } else if (currentIndex == 0) { pop(); } else { console.warn("There's no page to replace"); } return push(page, properties); } /** * Clears the page stack. * Destroy (or reparent) all the pages contained. */ function clear() { return columnView.clear(); } /** * @return the page at idx * @param idx the depth of the page we want */ function get(idx) { return columnView.contentChildren[idx]; } /** * go back to the previous index and scroll to the left to show one more column */ function flickBack() { if (depth > 1) { currentIndex = Math.max(0, currentIndex - 1); } } /** * layers: QtQuick.Controls.PageStack * Access to the modal layers. * Sometimes an application needs a modal page that always covers all the rows. * For instance the full screen image of an image viewer or a settings page. * @since 5.38 */ property alias layers: layersStack //END FUNCTIONS onInitialPageChanged: { if (initialPage) { clear(); push(initialPage, null) } } /* onActiveFocusChanged: { if (activeFocus) { layersStack.currentItem.forceActiveFocus() if (columnView.activeFocus) { print("SSS"+columnView.currentItem) columnView.currentItem.forceActiveFocus(); } } } */ Keys.forwardTo: [currentItem] GlobalToolBar.PageRowGlobalToolBarStyleGroup { id: globalToolBar readonly property int leftReservedSpace: globalToolBarUI.item ? globalToolBarUI.item.leftReservedSpace : 0 readonly property int rightReservedSpace: globalToolBarUI.item ? globalToolBarUI.item.rightReservedSpace : 0 readonly property int height: globalToolBarUI.height readonly property Item leftHandleAnchor: globalToolBarUI.item ? globalToolBarUI.item.leftHandleAnchor : null readonly property Item rightHandleAnchor: globalToolBarUI.item ? globalToolBarUI.item.rightHandleAnchor : null } QQC2.StackView { id: layersStack z: 99 anchors { fill: parent } //placeholder as initial item initialItem: columnView function clear () { //don't let it kill the main page row - var d = root.depth; + var d = layersStack.depth; for (var i = 1; i < d; ++i) { pop(); } } popEnter: Transition { OpacityAnimator { from: 0 to: 1 duration: Units.longDuration easing.type: Easing.InOutCubic } } popExit: Transition { ParallelAnimation { OpacityAnimator { from: 1 to: 0 duration: Units.longDuration easing.type: Easing.InOutCubic } YAnimator { from: 0 to: height/2 duration: Units.longDuration easing.type: Easing.InCubic } } } pushEnter: Transition { ParallelAnimation { //NOTE: It's a PropertyAnimation instead of an Animator because with an animator the item will be visible for an instant before starting to fade PropertyAnimation { property: "opacity" from: 0 to: 1 duration: Units.longDuration easing.type: Easing.InOutCubic } YAnimator { from: height/2 to: 0 duration: Units.longDuration easing.type: Easing.OutCubic } } } pushExit: Transition { OpacityAnimator { from: 1 to: 0 duration: Units.longDuration easing.type: Easing.InOutCubic } } replaceEnter: Transition { ParallelAnimation { OpacityAnimator { from: 0 to: 1 duration: Units.longDuration easing.type: Easing.InOutCubic } YAnimator { from: height/2 to: 0 duration: Units.longDuration easing.type: Easing.OutCubic } } } replaceExit: Transition { ParallelAnimation { OpacityAnimator { from: 1 to: 0 duration: Units.longDuration easing.type: Easing.InCubic } YAnimator { from: 0 to: -height/2 duration: Units.longDuration easing.type: Easing.InOutCubic } } } } Loader { id: globalToolBarUI anchors { left: parent.left top: parent.top right: parent.right } z: 100 active: globalToolBar.actualStyle != ApplicationHeaderStyle.None || (firstVisibleItem && firstVisibleItem.globalToolBarStyle == ApplicationHeaderStyle.ToolBar) visible: active height: active ? implicitHeight : 0 source: Qt.resolvedUrl("private/globaltoolbar/PageRowGlobalToolBarUI.qml"); } QtObject { id: pagesLogic readonly property var componentCache: new Array() function initAndInsertPage(position, page, properties) { var pageComp; if (page.createObject) { // page defined as component pageComp = page; } else if (typeof page == "string") { // page defined as string (a url) pageComp = pagesLogic.componentCache[page]; if (!pageComp) { pageComp = pagesLogic.componentCache[page] = Qt.createComponent(page); } } if (pageComp) { // instantiate page from component // FIXME: parent directly to columnView or root? page = pageComp.createObject(null, properties || {}); columnView.insertItem(position, page); if (pageComp.status === Component.Error) { throw new Error("Error while loading page: " + pageComp.errorString()); } } else { // copy properties to the page for (var prop in properties) { if (properties.hasOwnProperty(prop)) { page[prop] = properties[prop]; } } columnView.insertItem(position, page); } return page; } } ColumnView { id: columnView topPadding: globalToolBarUI.item && globalToolBarUI.item.breadcrumbVisible ? globalToolBarUI.height : 0 // Internal hidden api for Page readonly property Item __pageRow: root columnResizeMode: root.wideMode ? ColumnView.FixedColumns : ColumnView.SingleColumn columnWidth: root.defaultColumnWidth onItemInserted: root.pageInserted(position, item); onItemRemoved: root.pageRemoved(item); } Rectangle { anchors.bottom: parent.bottom height: Units.smallSpacing x: (columnView.width - width) * (columnView.contentX / (columnView.contentWidth - columnView.width)) width: columnView.width * (columnView.width/columnView.contentWidth) color: Theme.textColor opacity: 0 onXChanged: { opacity = 0.3 scrollIndicatorTimer.restart(); } Behavior on opacity { OpacityAnimator { duration: Units.longDuration easing.type: Easing.InOutQuad } } Timer { id: scrollIndicatorTimer interval: Units.longDuration * 4 onTriggered: parent.opacity = 0; } } } diff --git a/src/pagepool.cpp b/src/pagepool.cpp index 880409ce..83355873 100644 --- a/src/pagepool.cpp +++ b/src/pagepool.cpp @@ -1,295 +1,298 @@ /* * 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(); + 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]; - } + auto found = m_itemForUrl.find(actualUrl); + if (found != m_itemForUrl.end()) { + m_lastLoadedUrl = found.key(); + m_lastLoadedItem = found.value(); - if (foundItem) { if (callback.isCallable()) { - QJSValueList args = {qmlEngine(this)->newQObject(foundItem)}; + QJSValueList args = {qmlEngine(this)->newQObject(found.value())}; callback.call(args); - m_lastLoadedUrl = actualUrl; emit lastLoadedUrlChanged(); + emit lastLoadedItemChanged(); // We could return the item, but for api coherence return null return nullptr; + } else { - m_lastLoadedUrl = actualUrl; emit lastLoadedUrlChanged(); - return foundItem; + emit lastLoadedItemChanged(); + return found.value(); } } 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; } 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); } +QQuickItem *PagePool::pageForUrl(const QUrl &url) const +{ + return m_itemForUrl.value(resolvedUrl(url.toString()), nullptr); +} + 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); + 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_urlForItem.clear(); 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 cb07d2d6..daf3e84f 100644 --- a/src/pagepool.h +++ b/src/pagepool.h @@ -1,117 +1,122 @@ /* * 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 The page associated with a given URL, nullptr if there is no correspondence + */ + Q_INVOKABLE QQuickItem *pageForUrl(const QUrl &url) 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; };