You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
470 lines
19 KiB
470 lines
19 KiB
/* |
|
* Copyright 2019 Kai Uwe Broulik <kde@privat.broulik.de> |
|
* |
|
* 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 <http://www.gnu.org/licenses/> |
|
*/ |
|
|
|
pragma Singleton |
|
import QtQuick 2.8 |
|
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 Components |
|
import org.kde.kquickcontrolsaddons 2.0 |
|
|
|
import org.kde.notificationmanager 1.0 as NotificationManager |
|
|
|
import ".." |
|
|
|
// This singleton object contains stuff shared between all notification plasmoids, namely: |
|
// - Popup creation and placement |
|
// - Do not disturb mode |
|
QtObject { |
|
id: globals |
|
|
|
// Listened to by "ago" label in NotificationHeader to update all of them in unison |
|
signal timeChanged |
|
|
|
property bool inhibited: false |
|
|
|
onInhibitedChanged: { |
|
var pa = pulseAudio.item; |
|
if (!pa) { |
|
return; |
|
} |
|
|
|
var stream = pa.notificationStream; |
|
if (!stream) { |
|
return; |
|
} |
|
|
|
if (inhibited) { |
|
// Only remember that we muted if previously not muted. |
|
if (!stream.muted) { |
|
notificationSettings.notificationSoundsInhibited = true; |
|
stream.mute(); |
|
} |
|
} else { |
|
// Only unmute if we previously muted it. |
|
if (notificationSettings.notificationSoundsInhibited) { |
|
stream.unmute(); |
|
} |
|
notificationSettings.notificationSoundsInhibited = false; |
|
} |
|
notificationSettings.save(); |
|
} |
|
|
|
// Some parts of the code rely on plasmoid.nativeInterface and since we're in a singleton here |
|
// this is named "plasmoid" |
|
property QtObject plasmoid: plasmoids[0] |
|
|
|
// HACK When a plasmoid is destroyed, QML sets its value to "null" in the Array |
|
// so we then remove it so we have a working "plasmoid" again |
|
onPlasmoidChanged: { |
|
if (!plasmoid) { |
|
// this doesn't emit a change, only in ratePlasmoids() it will detect the change |
|
plasmoids.splice(0, 1); // remove first |
|
ratePlasmoids(); |
|
} |
|
} |
|
|
|
// all notification plasmoids |
|
property var plasmoids: [] |
|
|
|
property int popupLocation: { |
|
switch (notificationSettings.popupPosition) { |
|
// Auto-determine location based on plasmoid location |
|
case NotificationManager.Settings.CloseToWidget: |
|
if (!plasmoid) { |
|
return Qt.AlignBottom | Qt.AlignRight; // just in case |
|
} |
|
|
|
var alignment = 0; |
|
if (plasmoid.location === PlasmaCore.Types.LeftEdge) { |
|
alignment |= Qt.AlignLeft; |
|
} else if (plasmoid.location === PlasmaCore.Types.RightEdge) { |
|
alignment |= Qt.AlignRight; |
|
} else { |
|
// would be nice to do plasmoid.compactRepresentationItem.mapToItem(null) and then |
|
// position the popups depending on the relative position within the panel |
|
alignment |= Qt.application.layoutDirection === Qt.RightToLeft ? Qt.AlignLeft : Qt.AlignRight; |
|
} |
|
if (plasmoid.location === PlasmaCore.Types.TopEdge) { |
|
alignment |= Qt.AlignTop; |
|
} else { |
|
alignment |= Qt.AlignBottom; |
|
} |
|
return alignment; |
|
|
|
case NotificationManager.Settings.TopLeft: |
|
return Qt.AlignTop | Qt.AlignLeft; |
|
case NotificationManager.Settings.TopCenter: |
|
return Qt.AlignTop | Qt.AlignHCenter; |
|
case NotificationManager.Settings.TopRight: |
|
return Qt.AlignTop | Qt.AlignRight; |
|
case NotificationManager.Settings.BottomLeft: |
|
return Qt.AlignBottom | Qt.AlignLeft; |
|
case NotificationManager.Settings.BottomCenter: |
|
return Qt.AlignBottom | Qt.AlignHCenter; |
|
case NotificationManager.Settings.BottomRight: |
|
return Qt.AlignBottom | Qt.AlignRight; |
|
} |
|
} |
|
|
|
// The raw width of the popup's content item, the Dialog itself adds some margins |
|
property int popupWidth: 818 |
|
property int popupEdgeDistance: units.largeSpacing |
|
property int popupSpacing: units.largeSpacing |
|
|
|
// How much vertical screen real estate the notification popups may consume |
|
readonly property real popupMaximumScreenFill: 0.75 |
|
|
|
onPopupLocationChanged: Qt.callLater(positionPopups) |
|
|
|
Component.onCompleted: checkInhibition() |
|
|
|
function adopt(plasmoid) { |
|
// this doesn't emit a change, only in ratePlasmoids() it will detect the change |
|
globals.plasmoids.push(plasmoid); |
|
ratePlasmoids(); |
|
} |
|
|
|
// Sorts plasmoids based on a heuristic to find a suitable plasmoid to follow when placing popups |
|
function ratePlasmoids() { |
|
var plasmoidScore = function(plasmoid) { |
|
if (!plasmoid) { |
|
return 0; |
|
} |
|
|
|
var score = 0; |
|
|
|
// Prefer plasmoids in a panel, prefer horizontal panels over vertical ones |
|
if (plasmoid.location === PlasmaCore.Types.LeftEdge |
|
|| plasmoid.location === PlasmaCore.Types.RightEdge) { |
|
score += 1; |
|
} else if (plasmoid.location === PlasmaCore.Types.TopEdge |
|
|| plasmoid.location === PlasmaCore.Types.BottomEdge) { |
|
score += 2; |
|
} |
|
|
|
// Prefer iconified plasmoids |
|
if (!plasmoid.expanded) { |
|
++score; |
|
} |
|
|
|
// Prefer plasmoids on primary screen |
|
if (plasmoid.nativeInterface && plasmoid.nativeInterface.isPrimaryScreen(plasmoid.screenGeometry)) { |
|
++score; |
|
} |
|
|
|
return score; |
|
} |
|
|
|
var newPlasmoids = plasmoids; |
|
newPlasmoids.sort(function (a, b) { |
|
var scoreA = plasmoidScore(a); |
|
var scoreB = plasmoidScore(b); |
|
// Sort descending by score |
|
if (scoreA < scoreB) { |
|
return 1; |
|
} else if (scoreA > scoreB) { |
|
return -1; |
|
} else { |
|
return 0; |
|
} |
|
}); |
|
globals.plasmoids = newPlasmoids; |
|
} |
|
|
|
function checkInhibition() { |
|
globals.inhibited = Qt.binding(function() { |
|
var inhibited = false; |
|
|
|
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil; |
|
if (!isNaN(inhibitedUntil.getTime())) { |
|
inhibited |= (new Date().getTime() < inhibitedUntil.getTime()); |
|
} |
|
|
|
if (notificationSettings.notificationsInhibitedByApplication) { |
|
inhibited |= true; |
|
} |
|
|
|
if (notificationSettings.inhibitNotificationsWhenScreensMirrored) { |
|
inhibited |= notificationSettings.screensMirrored; |
|
} |
|
|
|
return inhibited; |
|
}); |
|
} |
|
|
|
function positionPopups() { |
|
if (!plasmoid) { |
|
return; |
|
} |
|
|
|
var screenRect = Qt.rect(plasmoid.screenGeometry.x + plasmoid.availableScreenRect.x, |
|
plasmoid.screenGeometry.y + plasmoid.availableScreenRect.y, |
|
plasmoid.availableScreenRect.width, |
|
plasmoid.availableScreenRect.height); |
|
if (screenRect.width <= 0 || screenRect.height <= 0) { |
|
return; |
|
} |
|
|
|
var y = screenRect.y; |
|
if (popupLocation & Qt.AlignBottom) { |
|
y += screenRect.height - popupEdgeDistance; |
|
} else { |
|
y += popupEdgeDistance; |
|
} |
|
|
|
var x = screenRect.x; |
|
if (popupLocation & Qt.AlignLeft) { |
|
x += popupEdgeDistance; |
|
} |
|
|
|
for (var i = 0; i < popupInstantiator.count; ++i) { |
|
let popup = popupInstantiator.objectAt(i); |
|
// Popup width is fixed, so don't rely on the actual window size |
|
var popupEffectiveWidth = popupWidth + popup.margins.left + popup.margins.right; |
|
|
|
if (popupLocation & Qt.AlignHCenter) { |
|
popup.x = x + (screenRect.width - popupEffectiveWidth) / 2; |
|
} else if (popupLocation & Qt.AlignRight) { |
|
popup.x = x + screenRect.width - popupEdgeDistance - popupEffectiveWidth; |
|
} else { |
|
popup.x = x; |
|
} |
|
|
|
if (popupLocation & Qt.AlignTop) { |
|
popup.y = y; |
|
// If the popup isn't ready yet, ignore its occupied space for now. |
|
// We'll reposition everything in onHeightChanged eventually. |
|
y += popup.height + (popup.height > 0 ? popupSpacing : 0); |
|
} else { |
|
y -= popup.height; |
|
popup.y = y; |
|
if (popup.height > 0) { |
|
y -= popupSpacing; |
|
} |
|
} |
|
|
|
// don't let notifications take more than popupMaximumScreenFill of the screen |
|
var visible = true; |
|
if (i > 0) { // however always show at least one popup |
|
if (popupLocation & Qt.AlignTop) { |
|
visible = (popup.y + popup.height < screenRect.y + (screenRect.height * popupMaximumScreenFill)); |
|
} else { |
|
visible = (popup.y > screenRect.y + (screenRect.height * (1 - popupMaximumScreenFill))); |
|
} |
|
} |
|
|
|
popup.visible = Qt.binding(function() { |
|
const dialog = plasmoid.nativeInterface.focussedPlasmaDialog; |
|
if (dialog && dialog.visible && dialog !== popup) { |
|
// If the notification obscures any other Plasma dialog, hide it |
|
// No rect intersects in JS... |
|
if (dialog.x < popup.x + popup.width && popup.x < dialog.x + dialog.width && dialog.y < popup.y + popup.height && popup.y < dialog.y + dialog.height) { |
|
return false; |
|
} |
|
} |
|
|
|
return visible; |
|
}); |
|
} |
|
} |
|
|
|
property QtObject popupNotificationsModel: NotificationManager.Notifications { |
|
limit: plasmoid ? (Math.ceil(plasmoid.availableScreenRect.height / (theme.mSize(theme.defaultFont).height * 4))) : 0 |
|
showExpired: false |
|
showDismissed: false |
|
blacklistedDesktopEntries: notificationSettings.popupBlacklistedApplications |
|
blacklistedNotifyRcNames: notificationSettings.popupBlacklistedServices |
|
whitelistedDesktopEntries: globals.inhibited ? notificationSettings.doNotDisturbPopupWhitelistedApplications : [] |
|
whitelistedNotifyRcNames: globals.inhibited ? notificationSettings.doNotDisturbPopupWhitelistedServices : [] |
|
showJobs: notificationSettings.jobsInNotifications |
|
sortMode: NotificationManager.Notifications.SortByTypeAndUrgency |
|
groupMode: NotificationManager.Notifications.GroupDisabled |
|
urgencies: { |
|
var urgencies = 0; |
|
|
|
// Critical always except in do not disturb mode when disabled in settings |
|
if (!globals.inhibited || notificationSettings.criticalPopupsInDoNotDisturbMode) { |
|
urgencies |= NotificationManager.Notifications.CriticalUrgency; |
|
} |
|
|
|
// Normal only when not in do not disturb mode |
|
if (!globals.inhibited) { |
|
urgencies |= NotificationManager.Notifications.NormalUrgency; |
|
} |
|
|
|
// Low only when enabled in settings and not in do not disturb mode |
|
if (!globals.inhibited && notificationSettings.lowPriorityPopups) { |
|
urgencies |=NotificationManager.Notifications.LowUrgency; |
|
} |
|
|
|
return urgencies; |
|
} |
|
} |
|
|
|
property QtObject notificationSettings: NotificationManager.Settings { |
|
onNotificationsInhibitedUntilChanged: globals.checkInhibition() |
|
} |
|
|
|
// This periodically checks whether do not disturb mode timed out and updates the "minutes ago" labels |
|
property QtObject timeSource: PlasmaCore.DataSource { |
|
engine: "time" |
|
connectedSources: ["Local"] |
|
interval: 60000 // 1 min |
|
intervalAlignment: PlasmaCore.Types.AlignToMinute |
|
onDataChanged: { |
|
checkInhibition(); |
|
globals.timeChanged(); |
|
} |
|
} |
|
|
|
property Instantiator popupInstantiator: Instantiator { |
|
model: popupNotificationsModel |
|
delegate: NotificationPopup { |
|
// so Instantiator can access that after the model row is gone |
|
readonly property var notificationId: model.notificationId |
|
|
|
popupWidth: globals.popupWidth |
|
type: model.urgency === NotificationManager.Notifications.CriticalUrgency && notificationSettings.keepCriticalAlwaysOnTop |
|
? PlasmaCore.Dialog.CriticalNotification : PlasmaCore.Dialog.Notification |
|
|
|
notificationType: model.type |
|
|
|
applicationName: model.applicationName |
|
applicationIconSource: model.applicationIconName |
|
originName: model.originName || "" |
|
|
|
time: model.updated || model.created |
|
|
|
configurable: model.configurable |
|
// For running jobs instead of offering a "close" button that might lead the user to |
|
// think that will cancel the job, we offer a "dismiss" button that hides it in the history |
|
dismissable: model.type === NotificationManager.Notifications.JobType |
|
&& model.jobState !== NotificationManager.Notifications.JobStateStopped |
|
// TODO would be nice to be able to "pin" jobs when they autohide |
|
&& notificationSettings.permanentJobPopups |
|
closable: model.closable |
|
|
|
summary: model.summary |
|
body: model.body || "" |
|
icon: model.image || model.iconName |
|
hasDefaultAction: model.hasDefaultAction || false |
|
timeout: model.timeout |
|
// Increase default timeout for notifications with a URL so you have enough time |
|
// to interact with the thumbnail or bring the window to the front where you want to drag it into |
|
defaultTimeout: notificationSettings.popupTimeout + (model.urls && model.urls.length > 0 ? 5000 : 0) |
|
// When configured to not keep jobs open permanently, we autodismiss them after the standard timeout |
|
dismissTimeout: !notificationSettings.permanentJobPopups |
|
&& model.type === NotificationManager.Notifications.JobType |
|
&& model.jobState !== NotificationManager.Notifications.JobStateStopped |
|
? defaultTimeout : 0 |
|
|
|
urls: model.urls || [] |
|
urgency: model.urgency || NotificationManager.Notifications.NormalUrgency |
|
|
|
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 || "" |
|
actionNames: model.actionNames |
|
actionLabels: model.actionLabels |
|
|
|
onExpired: popupNotificationsModel.expire(popupNotificationsModel.index(index, 0)) |
|
onHoverEntered: model.read = true |
|
onCloseClicked: popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) |
|
onDismissClicked: model.dismissed = true |
|
onConfigureClicked: popupNotificationsModel.configure(popupNotificationsModel.index(index, 0)) |
|
onDefaultActionInvoked: { |
|
popupNotificationsModel.invokeDefaultAction(popupNotificationsModel.index(index, 0)) |
|
popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) |
|
} |
|
onActionInvoked: { |
|
popupNotificationsModel.invokeAction(popupNotificationsModel.index(index, 0), actionName) |
|
popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) |
|
} |
|
onOpenUrl: { |
|
Qt.openUrlExternally(url); |
|
popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) |
|
} |
|
onFileActionInvoked: popupNotificationsModel.close(popupNotificationsModel.index(index, 0)) |
|
|
|
onSuspendJobClicked: popupNotificationsModel.suspendJob(popupNotificationsModel.index(index, 0)) |
|
onResumeJobClicked: popupNotificationsModel.resumeJob(popupNotificationsModel.index(index, 0)) |
|
onKillJobClicked: popupNotificationsModel.killJob(popupNotificationsModel.index(index, 0)) |
|
|
|
// popup width is fixed |
|
onHeightChanged: positionPopups() |
|
|
|
Component.onCompleted: { |
|
// Register apps that were seen spawning a popup so they can be configured later |
|
// Apps with notifyrc can already be configured anyway |
|
if (model.type === NotificationManager.Notifications.NotificationType && model.desktopEntry && !model.notifyRcName) { |
|
notificationSettings.registerKnownApplication(model.desktopEntry); |
|
notificationSettings.save(); |
|
} |
|
|
|
// Tell the model that we're handling the timeout now |
|
popupNotificationsModel.stopTimeout(popupNotificationsModel.index(index, 0)); |
|
} |
|
} |
|
onObjectAdded: { |
|
positionPopups(); |
|
object.visible = true; |
|
} |
|
onObjectRemoved: { |
|
var notificationId = object.notificationId |
|
// Popup might have been destroyed because of a filter change, tell the model to do the timeout work for us again |
|
// cannot use QModelIndex here as the model row is already gone |
|
popupNotificationsModel.startTimeout(notificationId); |
|
|
|
positionPopups(); |
|
} |
|
} |
|
|
|
// TODO use pulseaudio-qt for this once it becomes a framework |
|
property QtObject pulseAudio: Loader { |
|
source: "PulseAudio.qml" |
|
} |
|
|
|
property Connections screenWatcher: Connections { |
|
target: plasmoid |
|
onAvailableScreenRectChanged: repositionTimer.start() |
|
onScreenGeometryChanged: repositionTimer.start() |
|
} |
|
|
|
// Normally popups are repositioned through Qt.callLater but in case of e.g. screen geometry changes we want to compress that |
|
property Timer repositionTimer: Timer { |
|
interval: 250 |
|
onTriggered: positionPopups() |
|
} |
|
|
|
// Keeps the Inhibited property on DBus in sync with our inhibition handling |
|
property Binding serverInhibitedBinding: Binding { |
|
target: NotificationManager.Server |
|
property: "inhibited" |
|
value: globals.inhibited |
|
} |
|
}
|
|
|