/* * SPDX-FileCopyrightText: 2012-2016 Eike Hein * SPDX-FileCopyrightText: 2016 Kai Uwe Broulik * * SPDX-License-Identifier: GPL-2.0-or-later */ import QtQuick import QtQuick.Layouts import QtQuick.Controls import org.kde.plasma.plasmoid import org.kde.kirigami as Kirigami import org.kde.ksvg as KSvg import org.kde.plasma.core as PlasmaCore import org.kde.plasma.extras 2.0 as PlasmaExtras import org.kde.plasma.components 3.0 as PlasmaComponents import org.kde.plasma.private.mpris as Mpris import org.kde.kwindowsystem import org.kde.taskmanager as TaskManager import "code/layoutmetrics.js" as LayoutManager import org.kde.plasma.extras as PlasmaExtras /* * This is the custom context menu control for SevenTasks. * It is designed to look and feel like the context menu from Windows Vista and onwards, * while making sure that it behaves just like a normal context menu under KDE. This means: * * 1. The context menu grabs *all* mouse and key inputs. * 2. The context menu must disappear if an event outside of it causes it to 'lose focus'. * 3. The context menu must disappear if a menu item has been activated either with the mouse or the keyboard. * 4. The context menu must disappear if the user clicks away from it or the Escape key is pressed on the keyboard. * * As PlasmaCore.Dialog inherits QWindow, we can use QWindow::setMouseGrabEnabled(bool) and QWindow::setKeyboardGrabEnabled(bool) * to steal all mouse and keyboard events from the system and direct it towards the context menu. This is done through C++, more * info in the C++ source files. * */ PlasmaCore.Dialog { id: tasksMenu // Properties passed by the task when the context menu is created dynamically. // Context menu specific stuff. property QtObject backend property QtObject mpris2Source property var modelIndex readonly property var atm: TaskManager.AbstractTasksModel property var menuDecoration: "exec" property QtObject currentItem: null property int currentItemIndex: -1 property int taskWidth: 0 property int taskHeight: 0 property int taskX: 0 property int taskY: 0 readonly property int menuItemHeight: Kirigami.Units.smallSpacing*5 readonly property int menuWidth: 238 readonly property int slide: Kirigami.Units.smallSpacing*3 property bool finishedAnimating: false property bool showAllPlaces: false property bool alsoCloseTask: false property bool secondaryColumn: false property color backgroundColorStatic: "#f1f6fb" property color backgroundColorGradient: "white" property color borderColor: "#ccd9ea" property alias sliderAnimation: sliderAnimation // Functions inherited from the original ContextMenu function get(modelProp) { return tasksModel.data(modelIndex, modelProp) } function showContextMenuWithAllPlaces() { visualParent.showContextMenu({showAllPlaces: true}); } function newPlasmaMenuItem(parent) { return Qt.createQmlObject(` import org.kde.plasma.extras 2.0 as PlasmaExtras PlasmaExtras.MenuItem {} `, parent); } function newPlasmaSeparator(parent) { return Qt.createQmlObject(` import org.kde.plasma.extras 2.0 as PlasmaExtras PlasmaExtras.MenuItem { separator: true } `, parent); } function newMenuItem(parent) { return Qt.createQmlObject(` TasksMenuItemWrapper {} `, parent); } function newSeparator(parent) { return Qt.createQmlObject(` TasksMenuItemSeparator {} `, parent); } function clearIndices() { if(currentItem !== null) { currentItem.selected = false; currentItem = null; } currentItemIndex = -1; } function setCurrentItem(obj) { clearIndices(); var i = Array.prototype.indexOf.call(menuitems.children, obj); if(i === -1) { i = menuitems.children.length + Array.prototype.indexOf.call(staticMenuItems.children, obj); } currentItemIndex = i; currentItem = obj; currentItem.selected = true; } // Tasksmenu specific stuff property alias tMenu: tasksMenu property int xpos: -1 // Variable is used to keep track of the original x position which sometimes gets changed for no reason. visible: false opacity: 0 objectName: "tasksMenuDialog" hideOnWindowDeactivate: true // Makes it so that the context menu disappears if it gets forcibly out of focus by an external event. flags: Qt.WindowStaysOnTopHint | Qt.Dialog // Used to animate the context menu appearing and disappearing. Behavior on opacity { NumberAnimation { duration: 100; } } Behavior on y { NumberAnimation { id: sliderAnimation onRunningChanged: { Plasmoid.setMouseGrab(true, tasksMenu); } duration: 150; } } // Tries to detect when the x position resets to 0. onXChanged: { if(tasksMenu.x !== xpos) { tasksMenu.x = xpos; } } onHeightChanged: { if(KWindowSystem.isPlatformWayland) setPopupPosition(); } function setPopupPosition() { var globalPos = parent.mapToGlobal(tasks.x, tasks.y); var screen = tasks.screenGeometry; tasksMenu.y = globalPos.y - tasksMenu.height - ((menuitems.isEmpty() && KWindowSystem.isPlatformWayland) ? Kirigami.Units.smallSpacing*3 : 0); // Wayland bugs out with small jumplists for some reason var parentPos = parent.mapToGlobal(taskX, taskY); xpos = parentPos.x + taskWidth / 2; tasksMenu.x = parentPos.x + taskWidth / 2; xpos = parentPos.x + taskWidth / 2 - Kirigami.Units.largeSpacing + 1; xpos -= menuWidth / 2; if(xpos <= screen.x) { xpos = screen.x + Kirigami.Units.largeSpacing; tasksMenu.x = screen.x + Kirigami.Units.largeSpacing; } if((xpos+tasksMenu.menuWidth) > (screen.x+screen.width)) { xpos = screen.x + screen.width - tasksMenu.menuWidth - Kirigami.Units.largeSpacing*3; tasksMenu.x = screen.x + screen.width - tasksMenu.menuWidth - Kirigami.Units.largeSpacing*3; } tasksMenu.x = xpos; } // If the context menu is no longer visible (most often when it loses focus), close the menu. onVisibleChanged: { if(visible) { setPopupPosition(); } else if(!visible) { tasksMenu.closeMenu(); } } onActiveChanged: { if(!active) tasksMenu.closeMenu(); } // Set to Floating so that the borders are visible all the time, even when it is right next to another object. location: PlasmaCore.Types.Floating; // Used publicly by other objects to show the dynamically created context menu. function show() { loadDynamicLauncherActions(get(atm.LauncherUrlWithoutIcon)); visible = true; if(KWindowSystem.isPlatformX11) tasksMenu.y -= slide; opacity = 1; Qt.callLater(() => {Plasmoid.setMouseGrab(true, tasksMenu); tasksMenu.x = xpos;}); if(xpos !== tasksMenu.x) tasksMenu.x = xpos; openTimer.start(); } // Closes the menu gracefully, by first showing a fade out animation before freeing the object from memory. function closeMenu() { Plasmoid.disableBlurBehind(tasksMenu); if(KWindowSystem.isPlatformX11) tasksMenu.y += slide; opacity = 0; closeTimer.start(); } function loadDynamicLauncherActions(launcherUrl) { var sections = [ { title: i18n("Frequent"), group: "places", actions: backend.placesActions(launcherUrl, showAllPlaces, tasksMenu) }, { title: i18n("Recent"), group: "recents", actions: backend.recentDocumentActions(launcherUrl, tasksMenu) }, { title: i18n("Tasks"), group: "actions", actions: backend.jumpListActions(launcherUrl, tasksMenu) } ] // C++ can override section heading by returning a QString as first action sections.forEach((section) => { if (typeof section.actions[0] === "string") { section.title = section.actions.shift(); // take first } }); // QMenu does not limit its width automatically. Even if we set a maximumWidth // it would just cut off text rather than eliding. So we do this manually. var textMetrics = Qt.createQmlObject("import QtQuick; TextMetrics {}", menuitems); var maximumWidth = LayoutManager.maximumContextMenuTextWidth() + Kirigami.Units.smallSpacing*2; for(var i = 0; i < sections.length; i++) { var section = sections[i]; if(section["actions"].length == 0) continue; // Make a separator header var sepHeader = tasksMenu.newSeparator(menuitems); sepHeader.menuText = section["title"]; //addItemToMenu(sepHeader); for(var j = 0; j < section["actions"].length; j++) { if(section["group"] == "recents" && j == section["actions"].length-2) continue; var mAction = section["actions"][j]; var mItem = tasksMenu.newMenuItem(menuitems); // Crude way of manually eliding... var elided = false; textMetrics.text = Qt.binding(function() { return mAction.text; }); while (textMetrics.width > maximumWidth) { mAction.text = mAction.text.slice(0, -1); elided = true; } if (elided) { mAction.text += "…"; } mItem.text = mAction.text; mItem.icon= mAction.icon; mItem.clicked.connect(mAction.trigger); //addItemToMenu(mItem); secondaryColumn = true; } } // Add Media Player control actions var playerData = mpris2Source.playerForLauncherUrl(launcherUrl, get(atm.AppPid)); if (playerData && playerData.canControl && !(get(atm.WinIdList) !== undefined && get(atm.WinIdList).length > 1)) { var sepHeader = tasksMenu.newSeparator(menuitems); sepHeader.menuText = i18n("Media"); var menuItem = tasksMenu.newMenuItem(menuitems); menuItem.text = i18nc("Play previous track", "Previous Track"); menuItem.icon = "media-skip-backward"; menuItem.enabled = Qt.binding(function() { return playerData.canGoPrevious; }); menuItem.clicked.connect(function() { playerData.Previous(); }); menuItem = tasksMenu.newMenuItem(menuitems); // PlasmaCore Menu doesn't actually handle icons or labels changing at runtime... // Since we're not using PlasmaCore Menu anymore, we can achieve runtime changes like this' menuItem.text = Qt.binding(function() { var playing = playerData.playbackStatus === Mpris.PlaybackStatus.Playing; // if CanPause, toggle the menu entry between Play & Pause, otherwise always use Play return playing && playerData.canPause ? i18nc("Pause playback", "Pause") : i18nc("Start playback", "Play"); }); menuItem.icon= Qt.binding(function() { var playing = playerData.playbackStatus === Mpris.PlaybackStatus.Playing; return playing && playerData.canPause ? "media-playback-pause" : "media-playback-start"; }); menuItem.enabled = Qt.binding(function() { var playing = playerData.playbackStatus === Mpris.PlaybackStatus.Playing; return playing ? playerData.canPause : playerData.canPlay; }); menuItem.clicked.connect(function() { var playing = playerData.playbackStatus === Mpris.PlaybackStatus.Playing; if (playing) { playerData.Pause(); } else { playerData.Play(); } }); menuItem = tasksMenu.newMenuItem(menuitems); menuItem.text = i18nc("Play next track", "Next Track"); menuItem.icon = "media-skip-forward"; menuItem.enabled = Qt.binding(function() { return playerData.canGoNext; }); menuItem.clicked.connect(function() { playerData.Next(); }); menuItem = tasksMenu.newMenuItem(menuitems); menuItem.text = i18nc("Stop playback", "Stop"); menuItem.icon= "media-playback-stop"; menuItem.enabled = Qt.binding(function() { return playerData.canStop; }); menuItem.clicked.connect(function() { playerData.Stop(); }); // If we don't have a window associated with the player but we can quit // it through MPRIS we'll offer a "Quit" option instead of "Close" if (!closeWindowItem.visible && playerData.canQuit) { menuItem = tasksMenu.newMenuItem(menuitems); menuItem.text = i18nc("Quit media player app", "Quit"); menuItem.icon= "application-exit"; menuItem.visible = Qt.binding(function() { return !closeWindowItem.visible; }); menuItem.clicked.connect(function() { playerData.Quit(); }); } // If we don't have a window associated with the player but we can raise // it through MPRIS we'll offer a "Restore" option if (get(atm.IsLauncher) && !startNewInstanceItem.visible && playerData.canRaise) { menuItem = tasksMenu.newMenuItem(menuitems); menuItem.text = i18nc("Open or bring to the front window of media player app", "Restore"); menuItem.icon = playerData.iconName; menuItem.visible = Qt.binding(function() { return !startNewInstanceItem.visible; }); menuItem.clicked.connect(function() { playerData.Raise(); }); } } // We allow mute/unmute whenever an application has a stream, regardless of whether it // is actually playing sound. // This way you can unmute, e.g. a telephony app, even after the conversation has ended, // so you still have it ringing later on. if (tasksMenu.visualParent.hasAudioStream) { var muteItem = tasksMenu.newMenuItem(menuitems); muteItem.checkable = true; muteItem.checked = Qt.binding(function() { return tasksMenu.visualParent && tasksMenu.visualParent.muted; }); muteItem.clicked.connect(function() { tasksMenu.visualParent.toggleMuted(); muteItem.text = !muteItem.checked ? "Unmute" : "Mute"; muteItem.icon = !muteItem.checked ? "audio-volume-muted" : "audio-volume-high"; }); muteItem.text = muteItem.checked ? "Unmute" : "Mute"; muteItem.icon = muteItem.checked ? "audio-volume-muted" : "audio-volume-high"; secondaryColumn = true; } } function delayedMenu(delay, func) { Plasmoid.disableBlurBehind(tasksMenu); tasksMenu.y += slide; opacity = 0; delayTimer.interval = delay; delayTimer.repeat = false; delayTimer.triggered.connect(func); delayTimer.start(); } FocusScope { id: fscope focus: true Layout.minimumWidth: menuWidth Layout.maximumWidth: menuWidth Layout.minimumHeight: staticMenuItems.height + menuitems.height + Kirigami.Units.smallSpacing*3 - (!menuitems.isEmpty() ? 0 : Kirigami.Units.smallSpacing*2) Layout.maximumHeight: staticMenuItems.height + menuitems.height + Kirigami.Units.smallSpacing*3 - (!menuitems.isEmpty() ? 0 : Kirigami.Units.smallSpacing*2) // This is the last resort to avoiding the dialog displacement bug. It's set to correct the x position at a delay of 18ms. // This may result in a brief but noticeable jump in position when the context menu is shown. //enabled: !sliderAnimation.running; Timer { id: delayTimer } Timer { id: openTimer interval: 25 repeat: false onTriggered: { tasksMenu.x = xpos; } } // Timer used to free the object from memory after the fade out animation has finished. Timer { id: closeTimer interval: 150 onTriggered: { tasksMenu.destroy(); } } ColumnLayout { id: menuitems z: 1 function isEmpty() { return menuitems.visibleChildren.length <= 2; } onHeightChanged: { if(sliderAnimation.running) tasksMenu.y -= tasksMenu.slide; } spacing: Kirigami.Units.smallSpacing/2 anchors { left: parent.left right: parent.right bottom: staticMenuItems.top leftMargin: Kirigami.Units.mediumSpacing rightMargin: Kirigami.Units.mediumSpacing bottomMargin: 10 } Item { Layout.fillHeight: true } Item { height: Kirigami.Units.smallSpacing } } ColumnLayout { id: staticMenuItems z: 1 spacing: Kirigami.Units.smallSpacing/2 anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom anchors.leftMargin: 6 anchors.bottomMargin: 6 anchors.rightMargin: 6 Item { height: 1 } TasksMenuItemWrapper { id: startNewInstanceItem visible: true text: get(atm.AppName) === "" ? get(atm.Decoration) : get(atm.AppName) icon: menuDecoration onClicked: tasksModel.requestNewInstance(modelIndex) } TasksMenuItemWrapper { id: launcherToggleAction text: "Pin program to taskbar" icon: "window-pin" visible: visualParent && get(atm.IsLauncher) !== true && get(atm.IsStartup) !== true && plasmoid.immutability !== PlasmaCore.Types.SystemImmutable && !doesBelongToCurrentActivity() enabled: visible function doesBelongToCurrentActivity() { return tasksModel.launcherActivities(get(atm.LauncherUrlWithoutIcon)).some(function(activity) { return activity === activityInfo.currentActivity || activity === activityInfo.nullUuid; }); } onClicked: { tasksModel.requestAddLauncher(get(atm.LauncherUrl)); tasksMenu.closeMenu(); } } TasksMenuItemWrapper { id: unpinFromTaskMan enabled: visible visible: (visualParent && Plasmoid.immutability !== PlasmaCore.Types.SystemImmutable && !launcherToggleAction.visible) text: i18n("Unpin program from taskbar") icon: "window-unpin" onClicked: { delayedMenu(150, function() { tasksModel.requestRemoveLauncher(get(atm.LauncherUrlWithoutIcon)); tasksMenu.destroy(); }); } } TasksMenuItemWrapper { id: closeWindowItem visible: (visualParent && get(atm.IsLauncher) !== true && get(atm.IsStartup) !== true) enabled: visualParent && get(atm.IsClosable) === true text: get(atm.IsGroupParent) ? "Close all windows" : "Close window" //icon: "window-close" RIP ????-2024...... icon: "window-close" // I'M BACK' onClicked: { alsoCloseTask = true; closeMenu(); } } /*TasksMenuItemWrapper { * id: testItem * Layout.fillWidth: true * Layout.preferredHeight: menuItemHeight * * text: "Test" * icon: "window-close" * onClicked: { } }*/ } Rectangle { id: bgRect visible: secondaryColumn anchors { top: parent.top bottom: bgStatic.top left: parent.left right: parent.right leftMargin: 0 rightMargin: 0 topMargin: 0 } gradient: Gradient { GradientStop { position: 0; color: backgroundColorStatic } GradientStop { position: 0.5; color: backgroundColorGradient } GradientStop { position: 1; color: backgroundColorStatic } } z: -2 } Rectangle { id: bgStatic anchors { top: staticMenuItems.top bottom: parent.bottom left: parent.left right: parent.right leftMargin: 0 rightMargin: 0 topMargin: -4 } Rectangle { id: bgStaticBorderLine anchors { top: parent.top left: parent.left right: parent.right } height: Kirigami.Units.smallSpacing gradient: Gradient { GradientStop { position: 0; color: borderColor } GradientStop { position: 1; color: "transparent"} } } z: -1 color: backgroundColorStatic visible: !menuitems.isEmpty(); } Rectangle { id: bgStaticGradient anchors { top: staticMenuItems.top bottom: parent.bottom left: parent.left right: parent.right leftMargin: 0 rightMargin: 0 topMargin: -4 } z: -1 gradient: Gradient { GradientStop { position: 0; color: backgroundColorStatic } GradientStop { position: 0.5; color: backgroundColorGradient } GradientStop { position: 1; color: backgroundColorStatic } } visible: menuitems.isEmpty(); } function decreaseItemIndex() { currentItemIndex--; if(currentItemIndex < 0) { currentItemIndex = menuitems.children.length + staticMenuItems.children.length - 1; } var temp = currentItemIndex; var container = menuitems.children; if(currentItemIndex >= menuitems.children.length) { temp -= menuitems.children.length; container = staticMenuItems.children; } if(container[temp].objectName !== "menuitemwrapper" || (container[temp].objectName === "menuitemwrapper" && (!container[temp].enabled || !container[temp].visible))) { decreaseItemIndex(); } else { if(currentItem !== null) currentItem.selected = false; container[temp].selected = true; currentItem = container[temp]; } } function increaseItemIndex() { currentItemIndex++; if(currentItemIndex == menuitems.children.length + staticMenuItems.children.length) { currentItemIndex = 0; } var temp = currentItemIndex; var container = menuitems.children; if(currentItemIndex >= menuitems.children.length) { temp -= menuitems.children.length; container = staticMenuItems.children; } if(container[temp].objectName !== "menuitemwrapper" || (container[temp].objectName === "menuitemwrapper" && (!container[temp].enabled || !container[temp].visible))) { increaseItemIndex(); } else { if(currentItem !== null) currentItem.selected = false; container[temp].selected = true; currentItem = container[temp]; } } Keys.onPressed: event => { if(event.key == Qt.Key_Up) { decreaseItemIndex(); } else if(event.key == Qt.Key_Down || event.key == Qt.Key_Tab) { increaseItemIndex(); } else if(event.key == Qt.Key_Escape) { tasksMenu.closeMenu(); } else if(event.key == Qt.Key_Enter || event.key == Qt.Key_Return) { if(currentItem !== null) { currentItem.clicked(); tasksMenu.closeMenu(); } } } /* * Connects the context menu with the C++ part of the plasmoid. * The native interface installs itself onto this dialog as an event filter, upon which * all mouse click events are captured. By checking if the mouse has been clicked outside of * the context menu, we can then safely close it. * * This works because right after creating the context menu, we have set this dialog window to * grab all mouse events, which mimicks the way context menus work under Linux. * */ Connections { target: Plasmoid; function onMouseEventDetected(mouse) { if(!fscope.contains(Plasmoid.getPosition(fscope)) && !sliderAnimation.running) { tasksMenu.closeMenu(); } } } } Component.onCompleted: { backend.showAllPlaces.connect(showContextMenuWithAllPlaces) tasksMenu.backgroundHints = 2; // Sets the dialog background to the solid SVG variant. tasksMenu.y = tasksMenu.taskY - tasksMenu.slide; Plasmoid.setMouseGrab(true, tasksMenu); } Component.onDestruction: { backend.showAllPlaces.disconnect(showContextMenuWithAllPlaces) if(alsoCloseTask) tasksModel.requestClose(modelIndex); } }