diff --git a/applets/notifications/package/contents/ui/DraggableDelegate.qml b/applets/notifications/package/contents/ui/DraggableDelegate.qml new file mode 100644 index 000000000..8b6f3aaa1 --- /dev/null +++ b/applets/notifications/package/contents/ui/DraggableDelegate.qml @@ -0,0 +1,56 @@ +/* + * Copyright 2019 Marco Martin + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License or (at your option) version 3 or any later version + * accepted by the membership of KDE e.V. (or its successor approved + * by the membership of KDE e.V.), which shall act as a proxy + * defined in Section 14 of version 3 of the license. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see + */ + +import QtQuick 2.10 +import org.kde.kirigami 2.11 as Kirigami + +MouseArea { + id: delegate + + property Item contentItem + property bool draggable: false + signal dismissRequested + + implicitWidth: contentItem ? contentItem.implicitWidth : 0 + implicitHeight: contentItem ? contentItem.implicitHeight : 0 + opacity: 1 - Math.min(1, 1.5 * Math.abs(x) / width) + + drag { + filterChildren: draggable + axis: Drag.XAxis + target: draggable && Kirigami.Settings.tabletMode ? this : null + } + + onReleased: { + if (Math.abs(x) > width / 2) { + delegate.dismissRequested(); + } else { + slideAnim.restart(); + } + } + + NumberAnimation { + id: slideAnim + target: delegate + property:"x" + to: 0 + duration: units.longDuration + } +} diff --git a/applets/notifications/package/contents/ui/FullRepresentation.qml b/applets/notifications/package/contents/ui/FullRepresentation.qml index 1d07fd588..98aaee876 100644 --- a/applets/notifications/package/contents/ui/FullRepresentation.qml +++ b/applets/notifications/package/contents/ui/FullRepresentation.qml @@ -1,570 +1,588 @@ /* * Copyright 2018-2019 Kai Uwe Broulik * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see */ -import QtQuick 2.8 +import QtQuick 2.10 import QtQuick.Layouts 1.1 import org.kde.plasma.plasmoid 2.0 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as PlasmaComponents import org.kde.plasma.components 3.0 as PlasmaComponents3 import org.kde.plasma.extras 2.0 as PlasmaExtras +import org.kde.kirigami 2.11 as Kirigami import org.kde.kcoreaddons 1.0 as KCoreAddons import org.kde.notificationmanager 1.0 as NotificationManager import "global" ColumnLayout{ // FIXME fix popup size when resizing panel smaller (so it collapses) //Layout.preferredWidth: units.gridUnit * 18 //Layout.preferredHeight: units.gridUnit * 24 //Layout.minimumWidth: units.gridUnit * 10 //Layout.minimumHeight: units.gridUnit * 15 Layout.fillHeight: plasmoid.formFactor === PlasmaCore.Types.Vertical spacing: units.smallSpacing // TODO these should be configurable in the future readonly property int dndMorningHour: 6 readonly property int dndEveningHour: 20 // HACK forward focus to the list onActiveFocusChanged: { if (activeFocus) { list.forceActiveFocus(); } } Connections { target: plasmoid onExpandedChanged: { if (plasmoid.expanded) { list.positionViewAtBeginning(); list.currentIndex = -1; } } } // header ColumnLayout { + id: header + visible: !Kirigami.Settings.isMobile Layout.fillWidth: true spacing: 0 RowLayout { Layout.fillWidth: true spacing: 0 RowLayout { id: dndRow spacing: units.smallSpacing PlasmaComponents3.CheckBox { id: dndCheck text: i18n("Do not disturb") spacing: units.smallSpacing checkable: true checked: Globals.inhibited // Let the menu open on press onPressed: { if (!Globals.inhibited) { dndMenu.date = new Date(); // shows ontop of CheckBox to hide the fact that it's unchecked // until you actually select something :) dndMenu.open(0, 0); } } // but disable only on click onClicked: { if (Globals.inhibited) { notificationSettings.notificationsInhibitedUntil = undefined; notificationSettings.revokeApplicationInhibitions(); // overrules current mirrored screen setup, updates again when screen configuration changes notificationSettings.screensMirrored = false; notificationSettings.save(); } } contentItem: RowLayout { spacing: dndCheck.spacing PlasmaCore.IconItem { Layout.leftMargin: dndCheck.mirrored ? 0 : dndCheck.indicator.width + dndCheck.spacing Layout.rightMargin: dndCheck.mirrored ? dndCheck.indicator.width + dndCheck.spacing : 0 source: "notifications-disabled" Layout.preferredWidth: units.iconSizes.smallMedium Layout.preferredHeight: units.iconSizes.smallMedium } PlasmaComponents.Label { text: i18n("Do not disturb") } } PlasmaComponents.ModelContextMenu { id: dndMenu property date date visualParent: dndCheck onClicked: { notificationSettings.notificationsInhibitedUntil = model.date; notificationSettings.save(); } model: { var model = []; // For 1 hour var d = dndMenu.date; d.setHours(d.getHours() + 1); d.setSeconds(0); model.push({date: d, text: i18n("For 1 hour")}); d = dndMenu.date; d.setHours(d.getHours() + 4); d.setSeconds(0); model.push({date: d, text: i18n("For 4 hours")}); // Until this evening if (dndMenu.date.getHours() < dndEveningHour) { d = dndMenu.date; // TODO make the user's preferred time schedule configurable d.setHours(dndEveningHour); d.setMinutes(0); d.setSeconds(0); model.push({date: d, text: i18n("Until this evening")}); } // Until next morning if (dndMenu.date.getHours() > dndMorningHour) { d = dndMenu.date; d.setDate(d.getDate() + 1); d.setHours(dndMorningHour); d.setMinutes(0); d.setSeconds(0); model.push({date: d, text: i18n("Until tomorrow morning")}); } // Until Monday // show Friday and Saturday, Sunday is "0" but for that you can use "until tomorrow morning" if (dndMenu.date.getDay() >= 5) { d = dndMenu.date; d.setHours(dndMorningHour); // wraps around if necessary d.setDate(d.getDate() + (7 - d.getDay() + 1)); d.setMinutes(0); d.setSeconds(0); model.push({date: d, text: i18n("Until Monday")}); } // Until "turned off" d = dndMenu.date; // Just set it to one year in the future so we don't need yet another "do not disturb enabled" property d.setFullYear(d.getFullYear() + 1); model.push({date: d, text: i18n("Until turned off")}); return model; } } } } Item { Layout.fillWidth: true } PlasmaComponents.ToolButton { iconName: "configure" // remove mnemonics tooltip: plasmoid.action("openKcm").text.replace(/([^&]*)&(.)([^&]*)/g, function (match, p1, p2, p3) { return p1.concat(p2, p3); }); visible: plasmoid.action("openKcm").enabled onClicked: plasmoid.action("openKcm").trigger() } PlasmaComponents.ToolButton { iconName: "edit-clear-history" tooltip: i18n("Clear History") enabled: plasmoid.action("clearHistory").visible onClicked: action_clearHistory() } } PlasmaExtras.DescriptiveLabel { Layout.leftMargin: dndCheck.mirrored ? 0 : dndCheck.indicator.width + 2 * dndCheck.spacing + units.iconSizes.smallMedium Layout.rightMargin: dndCheck.mirrored ? dndCheck.indicator.width + 2 * dndCheck.spacing + units.iconSizes.smallMedium : 0 Layout.fillWidth: true wrapMode: Text.WordWrap textFormat: Text.PlainText text: { if (!Globals.inhibited) { return ""; } var inhibitedUntil = notificationSettings.notificationsInhibitedUntil; var inhibitedByApp = notificationSettings.notificationsInhibitedByApplication; var inhibitedByMirroredScreens = notificationSettings.inhibitNotificationsWhenScreensMirrored && notificationSettings.screensMirrored; var sections = []; // Show until time if valid but not if too far int he future if (!isNaN(inhibitedUntil.getTime()) && inhibitedUntil.getTime() - new Date().getTime() < 365 * 24 * 60 * 60 * 1000 /* 1 year*/) { sections.push(i18nc("Do not disturb until date", "Until %1", KCoreAddons.Format.formatRelativeDateTime(inhibitedUntil, Locale.ShortFormat))); } if (inhibitedByApp) { var inhibitionAppNames = notificationSettings.notificationInhibitionApplications; var inhibitionAppReasons = notificationSettings.notificationInhibitionReasons; for (var i = 0, length = inhibitionAppNames.length; i < length; ++i) { var name = inhibitionAppNames[i]; var reason = inhibitionAppReasons[i]; if (reason) { sections.push(i18nc("Do not disturb until app has finished (reason)", "While %1 is active (%2)", name, reason)); } else { sections.push(i18nc("Do not disturb until app has finished", "While %1 is active", name)); } } } if (inhibitedByMirroredScreens) { sections.push(i18nc("Do not disturb because external mirrored screens connected", "Screens are mirrored")) } return sections.join(" ยท "); } visible: text !== "" } } PlasmaCore.SvgItem { + visible: header.visible elementId: "horizontal-line" Layout.fillWidth: true // why is this needed here but not in the delegate? Layout.preferredHeight: naturalSize.height svg: PlasmaCore.Svg { id: lineSvg imagePath: "widgets/line" } } // actual notifications PlasmaExtras.ScrollArea { Layout.fillWidth: true Layout.fillHeight: true Layout.preferredWidth: units.gridUnit * 18 Layout.preferredHeight: units.gridUnit * 24 ListView { id: list model: historyModel currentIndex: -1 Keys.onDeletePressed: { var idx = historyModel.index(currentIndex, 0); if (historyModel.data(idx, NotificationManager.Notifications.ClosableRole)) { historyModel.close(idx); // TODO would be nice to stay inside the current group when deleting an item } } Keys.onEnterPressed: Keys.onReturnPressed(event) Keys.onReturnPressed: { // Trigger default action, if any var idx = historyModel.index(currentIndex, 0); if (historyModel.data(idx, NotificationManager.Notifications.HasDefaultActionRole)) { historyModel.invokeDefaultAction(idx); return; } // Trigger thumbnail URL if there's one var urls = historyModel.data(idx, NotificationManager.Notifications.UrlsRole); if (urls && urls.length === 1) { Qt.openUrlExternally(urls[0]); historyModel.expire(idx); return; } // TODO for finished jobs trigger "Open" or "Open Containing Folder" action } Keys.onLeftPressed: setGroupExpanded(currentIndex, LayoutMirroring.enabled) Keys.onRightPressed: setGroupExpanded(currentIndex, !LayoutMirroring.enabled) Keys.onPressed: { switch (event.key) { case Qt.Key_Home: currentIndex = 0; break; case Qt.Key_End: currentIndex = count - 1; break; } } function isRowExpanded(row) { var idx = historyModel.index(row, 0); return historyModel.data(idx, NotificationManager.Notifications.IsGroupExpandedRole); } function setGroupExpanded(row, expanded) { var rowIdx = historyModel.index(row, 0); var persistentRowIdx = historyModel.makePersistentModelIndex(rowIdx); var persistentGroupIdx = historyModel.makePersistentModelIndex(historyModel.groupIndex(rowIdx)); historyModel.setData(rowIdx, expanded, NotificationManager.Notifications.IsGroupExpandedRole); // If the current item went away when the group collapsed, scroll to the group heading if (!persistentRowIdx || !persistentRowIdx.valid) { if (persistentGroupIdx && persistentGroupIdx.valid) { list.positionViewAtIndex(persistentGroupIdx.row, ListView.Contain); // When closed via keyboard, also set a sane current index if (list.currentIndex > -1) { list.currentIndex = persistentGroupIdx.row; } } } } highlightMoveDuration: 0 highlightResizeDuration: 0 // Not using PlasmaComponents.Highlight as this is only for indicating keyboard focus highlight: PlasmaCore.FrameSvgItem { imagePath: "widgets/listitem" prefix: "pressed" } add: Transition { SequentialAnimation { PropertyAction { property: "opacity"; value: 0 } PauseAnimation { duration: units.longDuration } ParallelAnimation { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: units.longDuration } NumberAnimation { property: "height"; from: 0; duration: units.longDuration } } } } addDisplaced: Transition { NumberAnimation { properties: "y"; duration: units.longDuration } } remove: Transition { + id: removeTransition ParallelAnimation { NumberAnimation { property: "opacity"; to: 0; duration: units.longDuration } - NumberAnimation { property: "x"; to: list.width; duration: units.longDuration } + NumberAnimation { + property: "x" + to: removeTransition.ViewTransition.item.x >= 0 ? list.width : -list.width + duration: units.longDuration + } } } removeDisplaced: Transition { SequentialAnimation { PauseAnimation { duration: units.longDuration } NumberAnimation { properties: "y"; duration: units.longDuration } } } // This is so the delegates can detect the change in "isInGroup" and show a separator section { property: "isInGroup" criteria: ViewSection.FullString } - delegate: Loader { - id: delegateLoader + delegate: DraggableDelegate { width: list.width - sourceComponent: model.isGroup ? groupDelegate : notificationDelegate + contentItem: delegateLoader - Component { - id: groupDelegate - NotificationHeader { - applicationName: model.applicationName - applicationIconSource: model.applicationIconName - originName: model.originName || "" + draggable: !model.isGroup && model.type != NotificationManager.Notifications.JobType - // don't show timestamp for group + onDismissRequested: historyModel.close(historyModel.index(index, 0)); - configurable: model.configurable - closable: model.closable - closeButtonTooltip: i18n("Close Group") + Loader { + id: delegateLoader + width: list.width + sourceComponent: model.isGroup ? groupDelegate : notificationDelegate - onCloseClicked: { - historyModel.close(historyModel.index(index, 0)) - if (list.count === 0) { - plasmoid.expanded = false; - } - } + Component { + id: groupDelegate + NotificationHeader { + applicationName: model.applicationName + applicationIconSource: model.applicationIconName + originName: model.originName || "" - onConfigureClicked: historyModel.configure(historyModel.index(index, 0)) - } - } + // don't show timestamp for group - Component { - id: notificationDelegate - ColumnLayout { - spacing: units.smallSpacing - - RowLayout { - Item { - id: groupLineContainer - Layout.fillHeight: true - Layout.topMargin: units.smallSpacing - width: units.iconSizes.small - visible: model.isInGroup - - PlasmaCore.SvgItem { - elementId: "vertical-line" - svg: lineSvg - anchors.horizontalCenter: parent.horizontalCenter - width: units.iconSizes.small - height: parent.height + configurable: model.configurable + closable: model.closable + closeButtonTooltip: i18n("Close Group") + + onCloseClicked: { + historyModel.close(historyModel.index(index, 0)) + if (list.count === 0) { + plasmoid.expanded = false; } } - NotificationItem { - Layout.fillWidth: true + onConfigureClicked: historyModel.configure(historyModel.index(index, 0)) + } + } + + Component { + id: notificationDelegate + ColumnLayout { + spacing: units.smallSpacing - notificationType: model.type - - inGroup: model.isInGroup - - applicationName: model.applicationName - applicationIconSource: model.applicationIconName - originName: model.originName || "" - - time: model.updated || model.created - - // configure button on every single notifications is bit overwhelming - configurable: !inGroup && model.configurable - - dismissable: model.type === NotificationManager.Notifications.JobType - && model.jobState !== NotificationManager.Notifications.JobStateStopped - && model.dismissed - // TODO would be nice to be able to undismiss jobs even when they autohide - && notificationSettings.permanentJobPopups - dismissed: model.dismissed || false - closable: model.closable - - summary: model.summary - body: model.body || "" - icon: model.image || model.iconName - - urls: model.urls || [] - - jobState: model.jobState || 0 - percentage: model.percentage || 0 - jobError: model.jobError || 0 - suspendable: !!model.suspendable - killable: !!model.killable - jobDetails: model.jobDetails || null - - configureActionLabel: model.configureActionLabel || "" - // In the popup the default action is triggered by clicking on the popup - // however in the list this is undesirable, so instead show a clickable button - // in case you have a non-expired notification in history (do not disturb mode) - // unless it has the same label as an action - readonly property bool addDefaultAction: (model.hasDefaultAction - && model.defaultActionLabel - && (model.actionLabels || []).indexOf(model.defaultActionLabel) === -1) ? true : false - actionNames: { - var actions = (model.actionNames || []); - if (addDefaultAction) { - actions.unshift("default"); // prepend + RowLayout { + Item { + id: groupLineContainer + Layout.fillHeight: true + Layout.topMargin: units.smallSpacing + width: units.iconSizes.small + visible: model.isInGroup + + PlasmaCore.SvgItem { + elementId: "vertical-line" + svg: lineSvg + anchors.horizontalCenter: parent.horizontalCenter + width: units.iconSizes.small + height: parent.height } - return actions; } - actionLabels: { - var labels = (model.actionLabels || []); - if (addDefaultAction) { - labels.unshift(model.defaultActionLabel); + + NotificationItem { + Layout.fillWidth: true + + notificationType: model.type + + inGroup: model.isInGroup + + applicationName: model.applicationName + applicationIconSource: model.applicationIconName + originName: model.originName || "" + + time: model.updated || model.created + + // configure button on every single notifications is bit overwhelming + configurable: !inGroup && model.configurable + + dismissable: model.type === NotificationManager.Notifications.JobType + && model.jobState !== NotificationManager.Notifications.JobStateStopped + && model.dismissed + // TODO would be nice to be able to undismiss jobs even when they autohide + && notificationSettings.permanentJobPopups + dismissed: model.dismissed || false + closable: model.closable + + summary: model.summary + body: model.body || "" + icon: model.image || model.iconName + + urls: model.urls || [] + + jobState: model.jobState || 0 + percentage: model.percentage || 0 + jobError: model.jobError || 0 + suspendable: !!model.suspendable + killable: !!model.killable + jobDetails: model.jobDetails || null + + configureActionLabel: model.configureActionLabel || "" + // In the popup the default action is triggered by clicking on the popup + // however in the list this is undesirable, so instead show a clickable button + // in case you have a non-expired notification in history (do not disturb mode) + // unless it has the same label as an action + readonly property bool addDefaultAction: (model.hasDefaultAction + && model.defaultActionLabel + && (model.actionLabels || []).indexOf(model.defaultActionLabel) === -1) ? true : false + actionNames: { + var actions = (model.actionNames || []); + if (addDefaultAction) { + actions.unshift("default"); // prepend + } + return actions; + } + actionLabels: { + var labels = (model.actionLabels || []); + if (addDefaultAction) { + labels.unshift(model.defaultActionLabel); + } + return labels; } - return labels; - } - onCloseClicked: { - historyModel.close(historyModel.index(index, 0)); - if (list.count === 0) { + onCloseClicked: { + historyModel.close(historyModel.index(index, 0)); + if (list.count === 0) { + plasmoid.expanded = false; + } + } + onDismissClicked: { + model.dismissed = false; plasmoid.expanded = false; } - } - onDismissClicked: { - model.dismissed = false; - plasmoid.expanded = false; - } - onConfigureClicked: historyModel.configure(historyModel.index(index, 0)) - - onActionInvoked: { - if (actionName === "default") { - historyModel.invokeDefaultAction(historyModel.index(index, 0)); - } else { - historyModel.invokeAction(historyModel.index(index, 0), actionName); + onConfigureClicked: historyModel.configure(historyModel.index(index, 0)) + + onActionInvoked: { + if (actionName === "default") { + historyModel.invokeDefaultAction(historyModel.index(index, 0)); + } else { + historyModel.invokeAction(historyModel.index(index, 0), actionName); + } + // Keep it in the history + historyModel.expire(historyModel.index(index, 0)); } - // Keep it in the history - historyModel.expire(historyModel.index(index, 0)); - } - onOpenUrl: { - Qt.openUrlExternally(url); - historyModel.expire(historyModel.index(index, 0)); - } - onFileActionInvoked: historyModel.expire(historyModel.index(index, 0)) + onOpenUrl: { + Qt.openUrlExternally(url); + historyModel.expire(historyModel.index(index, 0)); + } + onFileActionInvoked: historyModel.expire(historyModel.index(index, 0)) - onSuspendJobClicked: historyModel.suspendJob(historyModel.index(index, 0)) - onResumeJobClicked: historyModel.resumeJob(historyModel.index(index, 0)) - onKillJobClicked: historyModel.killJob(historyModel.index(index, 0)) + onSuspendJobClicked: historyModel.suspendJob(historyModel.index(index, 0)) + onResumeJobClicked: historyModel.resumeJob(historyModel.index(index, 0)) + onKillJobClicked: historyModel.killJob(historyModel.index(index, 0)) + } } - } - PlasmaComponents.ToolButton { - Layout.preferredWidth: minimumWidth - iconName: model.isGroupExpanded ? "arrow-up" : "arrow-down" - text: model.isGroupExpanded ? i18n("Show Fewer") - : i18nc("Expand to show n more notifications", - "Show %1 More", (model.groupChildrenCount - model.expandedGroupChildrenCount)) - visible: (model.groupChildrenCount > model.expandedGroupChildrenCount || model.isGroupExpanded) - && delegateLoader.ListView.nextSection !== delegateLoader.ListView.section - onClicked: list.setGroupExpanded(model.index, !model.isGroupExpanded) - } + PlasmaComponents.ToolButton { + Layout.preferredWidth: minimumWidth + iconName: model.isGroupExpanded ? "arrow-up" : "arrow-down" + text: model.isGroupExpanded ? i18n("Show Fewer") + : i18nc("Expand to show n more notifications", + "Show %1 More", (model.groupChildrenCount - model.expandedGroupChildrenCount)) + visible: (model.groupChildrenCount > model.expandedGroupChildrenCount || model.isGroupExpanded) + && delegateLoader.ListView.nextSection !== delegateLoader.ListView.section + onClicked: list.setGroupExpanded(model.index, !model.isGroupExpanded) + } - PlasmaCore.SvgItem { - Layout.fillWidth: true - Layout.bottomMargin: units.smallSpacing - elementId: "horizontal-line" - svg: lineSvg + PlasmaCore.SvgItem { + Layout.fillWidth: true + Layout.bottomMargin: units.smallSpacing + elementId: "horizontal-line" + svg: lineSvg - // property is only atached to the delegate itself (the Loader in our case) - visible: (!model.isInGroup || delegateLoader.ListView.nextSection !== delegateLoader.ListView.section) - && delegateLoader.ListView.nextSection !== "" // don't show after last item + // property is only atached to the delegate itself (the Loader in our case) + visible: (!model.isInGroup || delegateLoader.ListView.nextSection !== delegateLoader.ListView.section) + && delegateLoader.ListView.nextSection !== "" // don't show after last item + } } } } } PlasmaExtras.Heading { width: list.width level: 3 opacity: 0.6 text: i18n("No unread notifications.") visible: list.count === 0 } } } } diff --git a/applets/notifications/package/contents/ui/NotificationPopup.qml b/applets/notifications/package/contents/ui/NotificationPopup.qml index 9e59bc179..2f6b2a259 100644 --- a/applets/notifications/package/contents/ui/NotificationPopup.qml +++ b/applets/notifications/package/contents/ui/NotificationPopup.qml @@ -1,210 +1,220 @@ /* * Copyright 2019 Kai Uwe Broulik * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see */ import QtQuick 2.8 import QtQuick.Layouts 1.1 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as Components import org.kde.notificationmanager 1.0 as NotificationManager import ".." PlasmaCore.Dialog { id: notificationPopup property int popupWidth property alias notificationType: notificationItem.notificationType property alias applicationName: notificationItem.applicationName property alias applicationIconSource: notificationItem.applicationIconSource property alias originName: notificationItem.originName property alias time: notificationItem.time property alias summary: notificationItem.summary property alias body: notificationItem.body property alias icon: notificationItem.icon property alias urls: notificationItem.urls property int urgency property int timeout property int dismissTimeout property alias jobState: notificationItem.jobState property alias percentage: notificationItem.percentage property alias jobError: notificationItem.jobError property alias suspendable: notificationItem.suspendable property alias killable: notificationItem.killable property alias jobDetails: notificationItem.jobDetails property alias configureActionLabel: notificationItem.configureActionLabel property alias configurable: notificationItem.configurable property alias dismissable: notificationItem.dismissable property alias closable: notificationItem.closable property bool hasDefaultAction property alias actionNames: notificationItem.actionNames property alias actionLabels: notificationItem.actionLabels signal configureClicked signal dismissClicked signal closeClicked signal defaultActionInvoked signal actionInvoked(string actionName) signal openUrl(string url) signal fileActionInvoked signal expired signal hoverEntered signal hoverExited signal suspendJobClicked signal resumeJobClicked signal killJobClicked property int defaultTimeout: 5000 readonly property int effectiveTimeout: { if (timeout === -1) { return defaultTimeout; } if (dismissTimeout) { return dismissTimeout; } return timeout; } location: PlasmaCore.Types.Floating flags: Qt.WindowDoesNotAcceptFocus visible: false // When notification is updated, restart hide timer onTimeChanged: { if (timer.running) { timer.restart(); } } - mainItem: MouseArea { - id: area + mainItem: Item { width: notificationPopup.popupWidth height: notificationItem.implicitHeight + notificationItem.y - hoverEnabled: true - - cursorShape: hasDefaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor - acceptedButtons: hasDefaultAction ? Qt.LeftButton : Qt.NoButton - - onClicked: notificationPopup.defaultActionInvoked() - onEntered: notificationPopup.hoverEntered() - onExited: notificationPopup.hoverExited() - - LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft - LayoutMirroring.childrenInherit: true - - Timer { - id: timer - interval: notificationPopup.effectiveTimeout - running: notificationPopup.visible && !area.containsMouse && interval > 0 - && !notificationItem.dragging && !notificationItem.menuOpen - onTriggered: { - if (notificationPopup.dismissTimeout) { - notificationPopup.dismissClicked(); - } else { - notificationPopup.expired(); - } - } - } + DraggableDelegate { + id: area + width: parent.width + height: parent.height + hoverEnabled: true + draggable: notificationItem.notificationType != NotificationManager.Notifications.JobType + onDismissRequested: popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) - Timer { - id: timeoutIndicatorDelayTimer - // only show indicator for the last ten seconds of timeout - readonly property int remainingTimeout: 10000 - interval: Math.max(0, timer.interval - remainingTimeout) - running: interval > 0 && timer.running - } + cursorShape: hasDefaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor + acceptedButtons: hasDefaultAction || draggable ? Qt.LeftButton : Qt.NoButton - Rectangle { - id: timeoutIndicatorRect - anchors { - right: parent.right - rightMargin: -notificationPopup.margins.right - bottom: parent.bottom - bottomMargin: -notificationPopup.margins.bottom + onClicked: { + if (hasDefaultAction) { + notificationPopup.defaultActionInvoked(); + } } - width: units.devicePixelRatio * 3 - color: theme.highlightColor - opacity: timeoutIndicatorAnimation.running ? 0.6 : 0 - visible: units.longDuration > 1 - Behavior on opacity { - NumberAnimation { - duration: units.longDuration + onEntered: notificationPopup.hoverEntered() + onExited: notificationPopup.hoverExited() + + LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft + LayoutMirroring.childrenInherit: true + + Timer { + id: timer + interval: notificationPopup.effectiveTimeout + running: notificationPopup.visible && !area.containsMouse && interval > 0 + && !notificationItem.dragging && !notificationItem.menuOpen + onTriggered: { + if (notificationPopup.dismissTimeout) { + notificationPopup.dismissClicked(); + } else { + notificationPopup.expired(); + } } } - NumberAnimation { - id: timeoutIndicatorAnimation - target: timeoutIndicatorRect - property: "height" - from: area.height + notificationPopup.margins.top + notificationPopup.margins.bottom - to: 0 - duration: Math.min(timer.interval, timeoutIndicatorDelayTimer.remainingTimeout) - running: timer.running && !timeoutIndicatorDelayTimer.running && units.longDuration > 1 + Timer { + id: timeoutIndicatorDelayTimer + // only show indicator for the last ten seconds of timeout + readonly property int remainingTimeout: 10000 + interval: Math.max(0, timer.interval - remainingTimeout) + running: interval > 0 && timer.running } - } - NotificationItem { - id: notificationItem - // let the item bleed into the dialog margins so the close button margins cancel out - y: closable || dismissable || configurable ? -notificationPopup.margins.top : 0 - headingRightPadding: -notificationPopup.margins.right - width: parent.width - hovered: area.containsMouse - maximumLineCount: 8 - bodyCursorShape: notificationPopup.hasDefaultAction ? Qt.PointingHandCursor : 0 - - thumbnailLeftPadding: -notificationPopup.margins.left - thumbnailRightPadding: -notificationPopup.margins.right - thumbnailTopPadding: -notificationPopup.margins.top - thumbnailBottomPadding: -notificationPopup.margins.bottom - - closable: true - onBodyClicked: { - if (area.acceptedButtons & mouse.button) { - area.clicked(null /*mouse*/); + Rectangle { + id: timeoutIndicatorRect + anchors { + right: parent.right + rightMargin: -notificationPopup.margins.right + bottom: parent.bottom + bottomMargin: -notificationPopup.margins.bottom + } + width: units.devicePixelRatio * 3 + color: theme.highlightColor + opacity: timeoutIndicatorAnimation.running ? 0.6 : 0 + visible: units.longDuration > 1 + Behavior on opacity { + NumberAnimation { + duration: units.longDuration + } + } + + NumberAnimation { + id: timeoutIndicatorAnimation + target: timeoutIndicatorRect + property: "height" + from: area.height + notificationPopup.margins.top + notificationPopup.margins.bottom + to: 0 + duration: Math.min(timer.interval, timeoutIndicatorDelayTimer.remainingTimeout) + running: timer.running && !timeoutIndicatorDelayTimer.running && units.longDuration > 1 + } + } + + NotificationItem { + id: notificationItem + // let the item bleed into the dialog margins so the close button margins cancel out + y: closable || dismissable || configurable ? -notificationPopup.margins.top : 0 + headingRightPadding: -notificationPopup.margins.right + width: parent.width + hovered: area.containsMouse + maximumLineCount: 8 + bodyCursorShape: notificationPopup.hasDefaultAction ? Qt.PointingHandCursor : 0 + + thumbnailLeftPadding: -notificationPopup.margins.left + thumbnailRightPadding: -notificationPopup.margins.right + thumbnailTopPadding: -notificationPopup.margins.top + thumbnailBottomPadding: -notificationPopup.margins.bottom + + closable: true + onBodyClicked: { + if (area.acceptedButtons & mouse.button) { + area.clicked(null /*mouse*/); + } } + onCloseClicked: notificationPopup.closeClicked() + onDismissClicked: notificationPopup.dismissClicked() + onConfigureClicked: notificationPopup.configureClicked() + onActionInvoked: notificationPopup.actionInvoked(actionName) + onOpenUrl: notificationPopup.openUrl(url) + onFileActionInvoked: notificationPopup.fileActionInvoked() + + onSuspendJobClicked: notificationPopup.suspendJobClicked() + onResumeJobClicked: notificationPopup.resumeJobClicked() + onKillJobClicked: notificationPopup.killJobClicked() } - onCloseClicked: notificationPopup.closeClicked() - onDismissClicked: notificationPopup.dismissClicked() - onConfigureClicked: notificationPopup.configureClicked() - onActionInvoked: notificationPopup.actionInvoked(actionName) - onOpenUrl: notificationPopup.openUrl(url) - onFileActionInvoked: notificationPopup.fileActionInvoked() - - onSuspendJobClicked: notificationPopup.suspendJobClicked() - onResumeJobClicked: notificationPopup.resumeJobClicked() - onKillJobClicked: notificationPopup.killJobClicked() } } } diff --git a/applets/notifications/package/contents/ui/SelectableLabel.qml b/applets/notifications/package/contents/ui/SelectableLabel.qml index c2077bbca..5a92b3f3d 100644 --- a/applets/notifications/package/contents/ui/SelectableLabel.qml +++ b/applets/notifications/package/contents/ui/SelectableLabel.qml @@ -1,175 +1,179 @@ /* * Copyright 2011 Marco Martin * Copyright 2014, 2019 Kai Uwe Broulik * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License or (at your option) version 3 or any later version * accepted by the membership of KDE e.V. (or its successor approved * by the membership of KDE e.V.), which shall act as a proxy * defined in Section 14 of version 3 of the license. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see */ import QtQuick 2.8 import QtQuick.Window 2.2 import QtQuick.Layouts 1.1 import org.kde.plasma.core 2.0 as PlasmaCore import org.kde.plasma.components 2.0 as PlasmaComponents import org.kde.plasma.extras 2.0 as PlasmaExtras +import org.kde.kirigami 2.11 as Kirigami // NOTE This wrapper item is needed for QQC ScrollView to work // In NotificationItem we just do SelectableLabel {} and then it gets confused // as to which is the "contentItem" Item { id: bodyTextContainer property alias text: bodyText.text property alias font: bodyText.font property int cursorShape property QtObject contextMenu: null signal clicked(var mouse) signal linkActivated(string link) implicitWidth: bodyText.paintedWidth implicitHeight: bodyText.paintedHeight + PlasmaExtras.ScrollArea { id: bodyTextScrollArea anchors.fill: parent flickableItem.boundsBehavior: Flickable.StopAtBounds flickableItem.flickableDirection: Flickable.VerticalFlick horizontalScrollBarPolicy: Qt.ScrollBarAlwaysOff TextEdit { id: bodyText width: bodyTextScrollArea.width // TODO check that this doesn't causes infinite loops when it starts adding and removing the scrollbar //width: bodyTextScrollArea.viewport.width - //enabled: !Settings.isMobile + enabled: !Kirigami.Settings.isMobile color: PlasmaCore.ColorScope.textColor selectedTextColor: theme.viewBackgroundColor selectionColor: theme.viewFocusColor font.capitalization: theme.defaultFont.capitalization font.family: theme.defaultFont.family font.italic: theme.defaultFont.italic font.letterSpacing: theme.defaultFont.letterSpacing font.pointSize: theme.defaultFont.pointSize font.strikeout: theme.defaultFont.strikeout font.underline: theme.defaultFont.underline font.weight: theme.defaultFont.weight font.wordSpacing: theme.defaultFont.wordSpacing // Work around Qt bug where NativeRendering breaks for non-integer scale factors // https://bugreports.qt.io/browse/QTBUG-67007 renderType: Screen.devicePixelRatio % 1 !== 0 ? Text.QtRendering : Text.NativeRendering - selectByMouse: true + // Selectable only when we are in desktop mode + selectByMouse: !Kirigami.Settings.tabletMode + readOnly: true wrapMode: Text.Wrap textFormat: TextEdit.RichText onLinkActivated: bodyTextContainer.linkActivated(link) // ensure selecting text scrolls the view as needed... onCursorRectangleChanged: { var flick = bodyTextScrollArea.flickableItem if (flick.contentY >= cursorRectangle.y) { flick.contentY = cursorRectangle.y } else if (flick.contentY + flick.height <= cursorRectangle.y + cursorRectangle.height) { flick.contentY = cursorRectangle.y + cursorRectangle.height - flick.height } } MouseArea { property int selectionStart property point mouseDownPos: Qt.point(-999, -999); anchors.fill: parent acceptedButtons: Qt.RightButton | Qt.LeftButton cursorShape: { if (bodyText.hoveredLink) { return Qt.PointingHandCursor; } else if (bodyText.selectionStart !== bodyText.selectionEnd) { return Qt.IBeamCursor; } else { return bodyTextContainer.cursorShape || Qt.IBeamCursor; } } preventStealing: true // don't let us accidentally drag the Flickable onPressed: { if (mouse.button === Qt.RightButton) { contextMenu = contextMenuComponent.createObject(bodyText); contextMenu.link = bodyText.linkAt(mouse.x, mouse.y); contextMenu.closed.connect(function() { contextMenu.destroy(); contextMenu = null; }); contextMenu.open(mouse.x, mouse.y); return; } mouseDownPos = Qt.point(mouse.x, mouse.y); selectionStart = bodyText.positionAt(mouse.x, mouse.y); var pos = bodyText.positionAt(mouse.x, mouse.y); // deselect() would scroll to the end which we don't want bodyText.select(pos, pos); } onReleased: { // emulate "onClicked" var manhattanLength = Math.abs(mouseDownPos.x - mouse.x) + Math.abs(mouseDownPos.y - mouse.y); if (manhattanLength <= Qt.styleHints.startDragDistance) { var link = bodyText.linkAt(mouse.x, mouse.y); if (link) { Qt.openUrlExternally(link); } else { bodyTextContainer.clicked(mouse); } } // emulate selection clipboard if (bodyText.selectedText) { plasmoid.nativeInterface.setSelectionClipboardText(bodyText.selectedText); } mouseDownPos = Qt.point(-999, -999); } // HACK to be able to select text whilst still getting all mouse events to the MouseArea onPositionChanged: { if (pressed) { var pos = bodyText.positionAt(mouseX, mouseY); if (selectionStart < pos) { bodyText.select(selectionStart, pos); } else { bodyText.select(pos, selectionStart); } } } } } } Component { id: contextMenuComponent EditContextMenu { target: bodyText } } }