/**************************************************************************** ** ** Copyright (C) 2016 The Qt Company Ltd. ** Contact: https://www.qt.io/licensing/ ** ** This file is part of the Qt Quick Extras module of the Qt Toolkit. ** ** $QT_BEGIN_LICENSE:LGPL$ ** Commercial License Usage ** Licensees holding valid commercial Qt licenses may use this file in ** accordance with the commercial license agreement provided with the ** Software or, alternatively, in accordance with the terms contained in ** a written agreement between you and The Qt Company. For licensing terms ** and conditions see https://www.qt.io/terms-conditions. For further ** information use the contact form at https://www.qt.io/contact-us. ** ** GNU Lesser General Public License Usage ** Alternatively, this file may be used under the terms of the GNU Lesser ** General Public License version 3 as published by the Free Software ** Foundation and appearing in the file LICENSE.LGPL3 included in the ** packaging of this file. Please review the following information to ** ensure the GNU Lesser General Public License version 3 requirements ** will be met: https://www.gnu.org/licenses/lgpl-3.0.html. ** ** GNU General Public License Usage ** Alternatively, this file may be used under the terms of the GNU ** General Public License version 2.0 or (at your option) the GNU General ** Public license version 3 or any later version approved by the KDE Free ** Qt Foundation. The licenses are as published by the Free Software ** Foundation and appearing in the file LICENSE.GPL2 and LICENSE.GPL3 ** included in the packaging of this file. Please review the following ** information to ensure the GNU General Public License requirements will ** be met: https://www.gnu.org/licenses/gpl-2.0.html and ** https://www.gnu.org/licenses/gpl-3.0.html. ** ** $QT_END_LICENSE$ ** ****************************************************************************/ import QtQuick 2.2 import QtQuick.Controls 1.4 import QtQuick.Controls.Styles 1.4 import QtQuick.Controls.Private 1.0 import QtQuick.Extras 1.4 import QtQuick.Extras.Private 1.0 import QtQuick.Extras.Private.CppUtils 1.0 as CppUtils /*! \qmltype PieMenu \inqmlmodule QtQuick.Extras \since 5.5 \ingroup extras \ingroup extras-interactive \brief A popup menu that displays several menu items along an arc. \image piemenu.png A PieMenu The PieMenu provides a radial context menu as an alternative to a traditional menu. All of the items in a PieMenu are an equal distance from the center of the control. \section2 Populating the Menu To create a menu, define at least one MenuItem as a child of it: \code PieMenu { id: pieMenu MenuItem { text: "Action 1" onTriggered: print("Action 1") } MenuItem { text: "Action 2" onTriggered: print("Action 2") } MenuItem { text: "Action 3" onTriggered: print("Action 3") } } \endcode By default, only the currently selected item's text is displayed above the menu. To provide text that is always visible when there is no current item, set the \l title property. \section2 Displaying the Menu The typical use case for a menu is to open at the point of the mouse cursor after a right click occurs. To do that, define a MouseArea that covers the region upon which clicks should open the menu. When the MouseArea is right-clicked, call the popup() function: \code MouseArea { anchors.fill: parent acceptedButtons: Qt.RightButton onClicked: pieMenu.popup(mouseX, mouseY) } \endcode If the menu is opened in a position where some of its menu items would be outside of \l boundingItem, it is automatically moved to a position where they will not be hidden. By default, the boundingItem is set to the parent of the menu. It can also be set to \c null to prevent this behavior. PieMenu can be displayed at any position on the screen. With a traditional context menu, the menu would be positioned with its top left corner at the position of the right click, but since PieMenu is radial, we position it centered over the position of the right click. To create a PieMenu that opens after a long press and selects items upon releasing, you can combine ActivationMode.ActivateOnRelease with a MouseArea using a Timer: \code MouseArea { id: touchArea anchors.fill: parent Timer { id: pressAndHoldTimer interval: 300 onTriggered: pieMenu.popup(touchArea.mouseX, touchArea.mouseY); } onPressed: pressAndHoldTimer.start() onReleased: pressAndHoldTimer.stop(); } PieMenu { id: pieMenu triggerMode: TriggerMode.TriggerOnRelease MenuItem { text: "Action 1" onTriggered: print("Action 1") } MenuItem { text: "Action 2" onTriggered: print("Action 2") } MenuItem { text: "Action 3" onTriggered: print("Action 3") } } \endcode You can hide individual menu items by setting their visible property to \c false. Hiding items does not affect the \l {PieMenuStyle::}{startAngle} or \l {PieMenuStyle::}{endAngle}; the remaining items will grow to consume the available space. You can create a custom appearance for a PieMenu by assigning a \l {PieMenuStyle} */ Control { id: pieMenu visible: false style: Settings.styleComponent(Settings.style, "PieMenuStyle.qml", pieMenu) /*! This property reflects the angle (in radians) created by the imaginary line from the center of the menu to the position of the cursor. Its value is undefined when the menu is not visible. */ readonly property real selectionAngle: { var centerX = width / 2; var centerY = height / 2; var targetX = __protectedScope.selectionPos.x; var targetY = __protectedScope.selectionPos.y; var xDistance = centerX - targetX; var yDistance = centerY - targetY; var angleToTarget = Math.atan2(xDistance, yDistance) * -1; angleToTarget; } /*! \qmlproperty enumeration PieMenu::activationMode This property determines the method for selecting items in the menu. \list \li An activationMode of \a ActivationMode.ActivateOnPress means that menu items will only be selected when a mouse press event occurs over them. \li An activationMode of \a ActivationMode.ActivateOnRelease means that menu items will only be selected when a mouse release event occurs over them. This means that the user must keep the mouse button down after opening the menu and release the mouse over the item they wish to select. \li An activationMode of \a ActivationMode.ActivateOnClick means that menu items will only be selected when the user clicks once over them. \endlist \warning Changing the activationMode while the menu is visible will result in undefined behavior. \deprecated Use triggerMode instead. */ property alias activationMode: pieMenu.triggerMode /*! \qmlproperty enumeration PieMenu::triggerMode This property determines the method for selecting items in the menu. \list \li A triggerMode of \a TriggerMode.TriggerOnPress means that menu items will only be selected when a mouse press event occurs over them. \li A triggerMode of \a TriggerMode.TriggerOnRelease means that menu items will only be selected when a mouse release event occurs over them. This means that the user must keep the mouse button down after opening the menu and release the mouse over the item they wish to select. \li A triggerMode of \a TriggerMode.TriggerOnClick means that menu items will only be selected when the user clicks once over them. \endlist \warning Changing the triggerMode while the menu is visible will result in undefined behavior. */ property int triggerMode: TriggerMode.TriggerOnClick /*! \qmlproperty list menuItems The list of menu items displayed by this menu. You can assign menu items by declaring them as children of PieMenu: \code PieMenu { MenuItem { text: "Action 1" onTriggered: function() { print("Action 1"); } } MenuItem { text: "Action 2" onTriggered: function() { print("Action 2"); } } MenuItem { text: "Action 3" onTriggered: function() { print("Action 3"); } } } \endcode */ default property alias menuItems: defaultPropertyHack.menuItems QtObject { // Can't specify a list as a default property (QTBUG-10822) id: defaultPropertyHack property list menuItems } /*! \qmlproperty int PieMenu::currentIndex The index of the the menu item that is currently under the mouse, or \c -1 if there is no such item. */ readonly property alias currentIndex: protectedScope.currentIndex /*! \qmlproperty int PieMenu::currentItem The menu item that is currently under the mouse, or \c null if there is no such item. */ readonly property alias currentItem: protectedScope.currentItem /*! This property defines the text that is shown above the menu when there is no current menu item (currentIndex is \c -1). The default value is \c "" (an empty string). */ property string title: "" /*! The item which the menu must stay within. A typical use case for PieMenu involves: \list \li A MouseArea that determines the clickable area within which the menu can be opened. \li The bounds that the menu must not go outside of. \endlist Although they sound similar, they have different purposes. Consider the example below: \image piemenu-boundingItem-example.png Canvas boundingItem example The user can only open the menu within the inner rectangle. In this case, they've opened the menu on the edge of the MouseArea, but there would not be enough room to display the entire menu centered at the cursor position, so it was moved to the left. If for some reason we didn't want this restriction, we can set boundingItem to \c null: \image piemenu-boundingItem-null-example.png Canvas null boundingItem example By default, the menu's \l {Item::}{parent} is the boundingItem. */ property Item boundingItem: parent /*! \qmlmethod void popup(real x, real y) Opens the menu at coordinates \a x, \a y. */ function popup(x, y) { if (x !== undefined) pieMenu.x = x - pieMenu.width / 2; if (y !== undefined) pieMenu.y = y - pieMenu.height / 2; pieMenu.visible = true; } /*! \qmlmethod void addItem(string text) Adds an item to the end of the menu items. Equivalent to passing calling \c insertItem(menuItems.length, text). Returns the newly added item. */ function addItem(text) { return insertItem(menuItems.length, text); } /*! \qmlmethod void insertItem(int before, string text) Inserts a MenuItem with \a text before the index at \a before. To insert an item at the end, pass \c menuItems.length. Returns the newly inserted item, or \c null if \a before is invalid. */ function insertItem(before, text) { if (before < 0 || before > menuItems.length) { return null; } var newItems = __protectedScope.copyItemsToJsArray(); var newItem = Qt.createQmlObject("import QtQuick.Controls 1.1; MenuItem {}", pieMenu, ""); newItem.text = text; newItems.splice(before, 0, newItem); menuItems = newItems; return newItem; } /*! \qmlmethod void removeItem(item) Removes \a item from the menu. */ function removeItem(item) { for (var i = 0; i < menuItems.length; ++i) { if (menuItems[i] === item) { var newItems = __protectedScope.copyItemsToJsArray(); newItems.splice(i, 1); menuItems = newItems; break; } } } MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: !Settings.hasTouchScreen && triggerMode !== TriggerMode.TriggerOnRelease acceptedButtons: Qt.LeftButton | Qt.RightButton onContainsMouseChanged: if (!containsMouse) __protectedScope.currentIndex = -1 objectName: "PieMenu internal MouseArea" // The mouse thief also updates the selectionPos, so we can't bind to // this mouseArea's mouseX/mouseY. onPositionChanged: { __protectedScope.selectionPos = Qt.point(mouseX, mouseY) } } /*! \internal */ property alias __mouseThief: mouseThief CppUtils.MouseThief { id: mouseThief onPressed: { __protectedScope.selectionPos = Qt.point(mouseX, mouseY); if (__protectedScope.handleEvent(ActivationMode.ActivateOnPress)) { mouseThief.acceptCurrentEvent(); // We handled the press event, so we can reset this now. mouseThief.receivedPressEvent = false; } } onReleased: { __protectedScope.selectionPos = Qt.point(mouseX, mouseY); if (__protectedScope.handleEvent(ActivationMode.ActivateOnRelease)) { mouseThief.acceptCurrentEvent(); // We handled the press event, so we can reset this now. mouseThief.receivedPressEvent = false; } __protectedScope.pressedIndex = -1; } onClicked: { __protectedScope.selectionPos = Qt.point(mouseX, mouseY); if (__protectedScope.handleEvent(ActivationMode.ActivateOnClick)) { mouseThief.acceptCurrentEvent(); } // Clicked is the last stage in a click event (press, release, click), // so we can safely set this to false now. mouseThief.receivedPressEvent = false; } onTouchUpdate: __protectedScope.selectionPos = Qt.point(mouseX, mouseY) } onVisibleChanged: { // parent check is for when it's created without a parent, // which we do in the tests, for example. if (parent) { if (visible) { if (boundingItem) __protectedScope.moveWithinBounds(); // We need to grab the mouse so that we can detect released() // (which is only emitted after pressed(), which our MouseArea can't // emit as it didn't have focus until we were made visible). mouseThief.grabMouse(mouseArea); } else { mouseThief.ungrabMouse(); __protectedScope.selectionPos = Qt.point(width / 2, height / 2); } } } onSelectionAngleChanged: __protectedScope.checkForCurrentItem() /*! \internal */ property QtObject __protectedScope: QtObject { id: protectedScope property int currentIndex: -1 property MenuItem currentItem: currentIndex != -1 ? visibleItems[currentIndex] : null property point selectionPos: Qt.point(width / 2, height / 2) property int pressedIndex: -1 readonly property var localRect: mapFromItem(mouseArea, mouseArea.mouseX, mouseArea.mouseY) readonly property var visibleItems: { var items = []; for (var i = 0; i < menuItems.length; ++i) { if (menuItems[i].visible) { items.push(menuItems[i]); } } return items; } onSelectionPosChanged: __protectedScope.checkForCurrentItem() // Can't bind directly, because the menu sets this to (0, 0) on closing. onLocalRectChanged: { if (visible) selectionPos = Qt.point(localRect.x, localRect.y); } function copyItemsToJsArray() { var newItems = []; for (var j = 0; j < menuItems.length; ++j) { newItems.push(menuItems[j]); } return newItems; } /*! Returns \c true if the mouse is over the section at \a itemIndex. */ function isMouseOver(itemIndex) { if (__style == null) return false; // Our mouse angle's origin is north naturally, but the section angles need to be // altered to have their origin north, so we need to remove the alteration here in order to compare properly. // For example, section 0 will start at -1.57, whereas we want it to start at 0. var sectionStart = __protectedScope.sectionStartAngle(itemIndex) + Math.PI / 2; var sectionEnd = __protectedScope.sectionEndAngle(itemIndex) + Math.PI / 2; var selAngle = selectionAngle; var isWithinOurAngle = false; if (sectionStart > CppUtils.MathUtils.pi2) { sectionStart %= CppUtils.MathUtils.pi2; } else if (sectionStart < -CppUtils.MathUtils.pi2) { sectionStart %= -CppUtils.MathUtils.pi2; } if (sectionEnd > CppUtils.MathUtils.pi2) { sectionEnd %= CppUtils.MathUtils.pi2; } else if (sectionEnd < -CppUtils.MathUtils.pi2) { sectionEnd %= -CppUtils.MathUtils.pi2; } // If the section crosses the -180 => 180 wrap-around point (from atan2), // temporarily rotate the section so it doesn't. if (sectionStart > Math.PI) { var difference = sectionStart - Math.PI; selAngle -= difference; sectionStart -= difference; sectionEnd -= difference; } else if (sectionStart < -Math.PI) { difference = Math.abs(sectionStart - (-Math.PI)); selAngle += difference; sectionStart += difference; sectionEnd += difference; } if (sectionEnd > Math.PI) { difference = sectionEnd - Math.PI; selAngle -= difference; sectionStart -= difference; sectionEnd -= difference; } else if (sectionEnd < -Math.PI) { difference = Math.abs(sectionEnd - (-Math.PI)); selAngle += difference; sectionStart += difference; sectionEnd += difference; } // If we moved the mouse past -180 or 180, we need to move it back within, // without changing its actual direction. if (selAngle > Math.PI) { selAngle = selAngle - CppUtils.MathUtils.pi2; } else if (selAngle < -Math.PI) { selAngle += CppUtils.MathUtils.pi2; } if (sectionStart > sectionEnd) { isWithinOurAngle = selAngle >= sectionEnd && selAngle < sectionStart; } else { isWithinOurAngle = selAngle >= sectionStart && selAngle < sectionEnd; } var x1 = width / 2; var y1 = height / 2; var x2 = __protectedScope.selectionPos.x; var y2 = __protectedScope.selectionPos.y; var distanceFromCenter = Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2); var cancelRadiusSquared = __style.cancelRadius * __style.cancelRadius; var styleRadiusSquared = __style.radius * __style.radius; var isWithinOurRadius = distanceFromCenter >= cancelRadiusSquared && distanceFromCenter < styleRadiusSquared; return isWithinOurAngle && isWithinOurRadius; } readonly property real arcRange: endAngleRadians - startAngleRadians /*! The size of one section in radians. */ readonly property real sectionSize: arcRange / visibleItems.length readonly property real startAngleRadians: CppUtils.MathUtils.degToRadOffset(__style.startAngle) readonly property real endAngleRadians: CppUtils.MathUtils.degToRadOffset(__style.endAngle) readonly property real circumferenceOfFullRange: 2 * Math.PI * __style.radius readonly property real percentageOfFullRange: (arcRange / (Math.PI * 2)) readonly property real circumferenceOfSection: (sectionSize / arcRange) * (percentageOfFullRange * circumferenceOfFullRange) function sectionStartAngle(section) { var start = startAngleRadians + section * sectionSize; return start; } function sectionCenterAngle(section) { return (sectionStartAngle(section) + sectionEndAngle(section)) / 2; } function sectionEndAngle(section) { var end = startAngleRadians + section * sectionSize + sectionSize; return end; } function handleEvent(eventType) { if (!visible) return false; checkForCurrentItem(); if (eventType === TriggerMode.TriggerOnPress) pressedIndex = currentIndex; if (eventType === TriggerMode.TriggerOnPress && triggerMode === TriggerMode.TriggerOnClick) { // We *MUST* accept press events if we plan on also accepting the release // (aka click, since we create that ourselves) event. If we don't, the // external mouse area gets the press event but not the release event, // and won't open until a release event is received, which means until the // user taps twice on the external mouse area. // Usually, we accept the current event in the onX MouseThief event handlers above, // but there we set receivedPressEvent to false if this function says it handled // the event, which we don't want, since TriggerOnClick is expecting to have // received a press event. So, we ensure that receivedPressEvent stays true // by saying we didn't handle the event, even though we actually do. mouseThief.acceptCurrentEvent(); return false; } if (triggerMode === eventType) { if (eventType === TriggerMode.TriggerOnClick && !mouseThief.receivedPressEvent) { // When the trigger mode is TriggerOnClick, we can't // act on a click event if we didn't receive the press. return false; } // Setting visible to false resets the selectionPos to the center // of the menu, which in turn causes the currentItem check to be re-evaluated, // which sees that there's no current item because the selectionPos is centered. // To avoid all of that, we store these variables before setting visible to false. var currentItemBeforeClosing = currentItem; var selectionPosBeforeClosing = selectionPos; var currentIndexBeforeClosing = currentIndex; // If the cursor was over an item; trigger it. If it wasn't, // close our menu regardless. We do this first so that it's // possible to keep the menu open by setting visible to true in onTriggered. visible = false; if (currentItemBeforeClosing) { currentItemBeforeClosing.trigger(); } if (visible && !Settings.hasTouchScreen && !Settings.isMobile) { // The user kept the menu open in onTriggered, so restore the hover stuff. selectionPos = selectionPosBeforeClosing; currentIndex = currentIndexBeforeClosing; } // If the trigger mode and event are Release, we should ensure // that we received a press event beforehand. If we didn't, we shouldn't steal // the event in MouseThief's event filter. return mouseThief.receivedPressEvent; } return false; } function checkForCurrentItem() { // Use a temporary varibable because setting currentIndex to -1 here // will trigger onCurrentIndexChanged. if (!!visibleItems) { var hoveredIndex = -1; for (var i = 0; i < visibleItems.length; ++i) { if (isMouseOver(i)) { hoveredIndex = i; break; } } currentIndex = hoveredIndex; } } function simplifyAngle(angle) { var simplified = angle % 360; if (simplified < 0) simplified += 360; return simplified; } function isWithinBottomEdge() { var start = simplifyAngle(pieMenu.__style.startAngle); var end = simplifyAngle(pieMenu.__style.endAngle); return start >= 270 && end <= 90 && ((start < 360 && end <= 360) || (start >= 0 && end > 0)); } function isWithinTopEdge() { var start = simplifyAngle(pieMenu.__style.startAngle); var end = simplifyAngle(pieMenu.__style.endAngle); return start >= 90 && start < 270 && end > 90 && end <= 270; } function isWithinLeftEdge() { var start = simplifyAngle(pieMenu.__style.startAngle); var end = simplifyAngle(pieMenu.__style.endAngle); return (start === 360 || start >= 0) && start < 180 && end > 0 && end <= 180; } function isWithinRightEdge() { var start = simplifyAngle(pieMenu.__style.startAngle); var end = simplifyAngle(pieMenu.__style.endAngle); return start >= 180 && start < 360 && end > 180 && (end === 360 || end === 0); } /*! Moves the menu if it would open with parts outside of \a rootParent. */ function moveWithinBounds() { // Find the bounding rect of the bounding item in the parent's referential. var topLeft = boundingItem.mapToItem(pieMenu.parent, 0, 0); var topRight = boundingItem.mapToItem(pieMenu.parent, boundingItem.width, 0); var bottomLeft = boundingItem.mapToItem(pieMenu.parent, 0, boundingItem.height); var bottomRight = boundingItem.mapToItem(pieMenu.parent, boundingItem.width, boundingItem.height); // If the boundingItem is rotated, normalize the bounding rect. topLeft.x = Math.min(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x); topLeft.y = Math.min(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y); bottomRight.x = Math.max(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x); bottomRight.y = Math.max(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y); if (pieMenu.x < topLeft.x && !isWithinLeftEdge()) { // The width and height of the menu is always that of a full circle, // so the menu is not always outside an edge when it's outside the edge - // it depends on the start and end angles. pieMenu.x = topLeft.x; } else if (pieMenu.x + pieMenu.width > bottomRight.x && !isWithinRightEdge()) { pieMenu.x = bottomRight.x - pieMenu.width; } if (pieMenu.y < topLeft.y && !isWithinTopEdge()) { pieMenu.y = topLeft.y; } else if (pieMenu.y + pieMenu.height > bottomRight.y && !isWithinBottomEdge()) { pieMenu.y = bottomRight.y - pieMenu.height; } } } }