Commit 54b4fc09 authored by Kai Uwe Broulik's avatar Kai Uwe Broulik 🍇

Implement do not disturb mode, more history work, cleanup

- Add basic do not disturb mode
  Can set a time until it enabled, persisted across reboots
  Whitelist for apps missing right not
  Inhibition API not wired up yet
- d-pointer JobDetails
- Use KFilePlacesModel for prettier destUrl reporting "Copying to Home"
- Expose default action in history as button
- Improved right-to-left language support
- Let NotificationServer just lurk (without registering a service)
- Catch when plasmoid is deleted and stick to another one
parent e93bba28
......@@ -24,81 +24,171 @@ 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.kcoreaddons 1.0 as KCoreAddons
import org.kde.notificationmanager 1.0 as NotificationManager
import "global"
ColumnLayout {
Layout.preferredWidth: units.gridUnit * 18
Layout.preferredHeight: units.gridUnit * 24
Layout.fillHeight: plasmoid.formFactor === PlasmaCore.Types.Vertical
spacing: units.smallSpacing
RowLayout {
// TODO these should be configurable in the future
readonly property int dndMorningHour: 6
readonly property int dndEveningHour: 20
// header
ColumnLayout {
Layout.fillWidth: true
spacing: 0
RowLayout {
id: dndRow
spacing: units.smallSpacing
PlasmaCore.IconItem {
// FIXME proper icon
source: "face-quiet"
Layout.preferredWidth: units.iconSizes.smallMedium
Layout.preferredHeight: units.iconSizes.smallMedium
}
Layout.fillWidth: true
PlasmaComponents.Label {
text: i18n("Do not disturb:")
}
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;
PlasmaComponents.ComboBox {
Layout.preferredWidth: units.gridUnit * 10 // FIXME
model: [
"Disabled",
"While Okular is active",
"For 1 hour",
"Until logging out",
"Until this evening",
"Until Monday morning"
]
}
}
notificationSettings.save();
}
}
Item {
Layout.fillWidth: true
}
contentItem: RowLayout {
PlasmaCore.IconItem {
Layout.leftMargin: dndCheck.mirrored ? 0 : dndCheck.indicator.width + dndCheck.spacing
Layout.rightMargin: dndCheck.mirrored ? dndCheck.indicator.width + dndCheck.spacing : 0
// FIXME proper icon
source: "face-quiet"
Layout.preferredWidth: units.iconSizes.smallMedium
Layout.preferredHeight: units.iconSizes.smallMedium
}
PlasmaComponents.ToolButton {
iconName: "configure"
tooltip: plasmoid.action("configure").text
visible: plasmoid.action("configure").enabled
onClicked: plasmoid.action("configure").trigger()
}
}
PlasmaComponents.Label {
text: i18n("Do not disturb")
}
}
RowLayout {
spacing: units.smallSpacing
Layout.leftMargin: units.iconSizes.smallMedium + units.smallSpacing
PlasmaComponents.ContextMenu {
id: dndMenu
property date date
visualParent: dndCheck
onTriggered: {
notificationSettings.notificationsInhibitedUntil = item.date;
notificationSettings.save();
}
PlasmaCore.IconItem {
Layout.preferredWidth: units.iconSizes.smallMedium
Layout.preferredHeight: units.iconSizes.smallMedium
source: "okular"
PlasmaComponents.MenuItem {
section: true
text: i18n("Do not disturb")
}
PlasmaComponents.MenuItem {
text: i18n("For 1 hour")
readonly property date date: {
var d = dndMenu.date;
d.setHours(d.getHours() + 1);
d.setSeconds(0);
return d;
}
}
PlasmaComponents.MenuItem {
text: i18n("Until this evening")
// TODO make the user's preferred time schedule configurable
visible: dndMenu.date.getHours() < dndEveningHour
readonly property date date: {
var d = dndMenu.date;
d.setHours(dndEveningHour);
d.setMinutes(0);
d.setSeconds(0);
return d;
}
}
PlasmaComponents.MenuItem {
text: i18n("Until tomorrow morning")
visible: dndMenu.date.getHours() > dndMorningHour
readonly property date date: {
var d = dndMenu.date;
d.setDate(d.getDate() + 1);
d.setHours(dndMorningHour);
d.setMinutes(0);
d.setSeconds(0);
return d;
}
}
PlasmaComponents.MenuItem {
text: i18n("Until Monday")
}
}
}
}
Item {
Layout.fillWidth: true
}
PlasmaComponents.ToolButton {
iconName: "configure"
tooltip: plasmoid.action("configure").text
visible: plasmoid.action("configure").enabled
onClicked: plasmoid.action("configure").trigger()
}
}
PlasmaExtras.DescriptiveLabel {
leftPadding: dndCheck.mirrored ? 0 : units.smallSpacing + dndCheck.indicator.width + dndCheck.spacing
rightPadding: dndCheck.mirrored ? units.smallSpacing + dndCheck.indicator.width + dndCheck.spacing : 0
Layout.fillWidth: true
text: i18n("Okular has enabled do not disturb mode: Giving a presentation")
textFormat: Text.PlainText
wrapMode: Text.WordWrap
maximumLineCount: 3 // just in case
textFormat: Text.PlainText
text: {
if (Globals.inhibited) {
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil
var inhibitedUntilValid = !isNaN(inhibitedUntil.getTime());
// TODO check app inhibition, too
if (inhibitedUntilValid) {
return i18nc("Do not disturb mode enabled until date", "Until %1",
KCoreAddons.Format.formatRelativeDateTime(inhibitedUntil, Locale.ShortFormat));
}
}
return "";
}
visible: text !== ""
}
}
PlasmaCore.SvgItem {
elementId: "horizontal-line"
Layout.fillWidth: true
Layout.preferredHeight: 2 // FIXME
// why is this needed here but not in the delegate?
Layout.preferredHeight: naturalSize.height
svg: PlasmaCore.Svg {
id: lineSvg
imagePath: "widgets/line"
......@@ -135,6 +225,7 @@ ColumnLayout {
ListView {
id: list
model: historyModel
spacing: units.smallSpacing
remove: Transition {
ParallelAnimation {
......@@ -210,28 +301,49 @@ ColumnLayout {
jobDetails: model.jobDetails || null
configureActionLabel: model.configureActionLabel || ""
actionNames: model.actionNames
actionLabels: model.actionLabels
// 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)
actionNames: {
var actions = (model.actionNames || []);
if (model.hasDefaultAction) {
actions.unshift("default"); // prepend
}
return actions;
}
actionLabels: {
var labels = (model.actionLabels || []);
if (model.hasDefaultAction) {
labels.unshift(model.defaultActionLabel || i18n("Open")); // make sure it has some label
}
return labels;
}
onCloseClicked: historyModel.close(historyModel.index(index, 0))
onDismissClicked: model.dismissed = false
onConfigureClicked: historyModel.configure(historyModel.index(index, 0))
onActionInvoked: {
historyModel.invokeAction(historyModel.index(index, 0), actionName);
//historyModel.close(historyModel.index(index, 0));
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));
}
onOpenUrl: {
Qt.openUrlExternally(url);
//historyModel.close(historyModel.index(index, 0))
historyModel.expire(historyModel.index(index, 0));
}
onFileActionInvoked: popupNotificationsModel.expire(popupNotificationsModel.index(index, 0))
onSuspendJobClicked: historyModel.suspendJob(historyModel.index(index, 0))
onResumeJobClicked: historyModel.resumeJob(historyModel.index(index, 0))
onKillJobClicked: historyModel.killJob(historyModel.index(index, 0))
// FIXME
svg: lineSvg
separatorSvg: lineSvg
separatorVisible: index < list.count - 1
}
}
}
......
......@@ -57,22 +57,22 @@ ColumnLayout {
property alias actionNames: notificationItem.actionNames
property alias actionLabels: notificationItem.actionLabels
property alias separatorSvg: lineSvgItem.svg
property alias separatorVisible: lineSvgItem.visible
signal configureClicked
signal dismissClicked
signal closeClicked
//signal defaultActionInvoked
signal actionInvoked(string actionName)
signal openUrl(string url)
signal fileActionInvoked
signal suspendJobClicked
signal resumeJobClicked
signal killJobClicked
// FIXME
property alias svg: lineSvgItem.svg
spacing: 0
spacing: units.smallSpacing
NotificationItem {
id: notificationItem
......@@ -86,6 +86,7 @@ ColumnLayout {
onActionInvoked: delegate.actionInvoked(actionName)
onOpenUrl: delegate.openUrl(url)
onFileActionInvoked: delegate.fileActionInvoked()
onSuspendJobClicked: delegate.suspendJobClicked()
onResumeJobClicked: delegate.resumeJobClicked()
......@@ -96,6 +97,5 @@ ColumnLayout {
id: lineSvgItem
elementId: "horizontal-line"
Layout.fillWidth: true
// TODO hide for last notification
}
}
......@@ -30,6 +30,8 @@ import org.kde.notificationmanager 1.0 as NotificationManager
import org.kde.kcoreaddons 1.0 as KCoreAddons
import "global"
RowLayout {
id: notificationHeading
......@@ -54,6 +56,7 @@ RowLayout {
signal dismissClicked
signal closeClicked
// notification created/updated time changed
onTimeChanged: updateAgoText()
function updateAgoText() {
......@@ -63,16 +66,11 @@ RowLayout {
spacing: units.smallSpacing
Layout.preferredHeight: Math.max(applicationNameLabel.implicitHeight, units.iconSizes.small)
// TODO this timer should probably be at a central location
// so every notification updates simultaneously
Timer {
interval: 60000
repeat: true
running: notificationHeading.visible
&& notificationHeading.Window.window
&& notificationHeading.Window.window.visible
triggeredOnStart: true
onTriggered: notificationHeading.updateAgoText()
Connections {
target: Globals
// clock time changed
// TODO should we do this only when actually visible/expanded?
onTimeChanged: notificationHeading.updateAgoText()
}
PlasmaCore.IconItem {
......@@ -101,7 +99,7 @@ RowLayout {
text: generateRemainingText() || agoText
function generateAgoText() {
if (!time || isNaN(time.getTime())) {
if (!time || isNaN(time.getTime()) || notificationHeading.jobState === NotificationManager.Notifications.JobStateRunning) {
return "";
}
......
......@@ -219,15 +219,27 @@ ColumnLayout {
Repeater {
id: actionRepeater
// HACK We want the actions to be right-aligned but Flow also reverses
// the order of items, so we manually reverse it here
model: (notificationItem.actionNames || []).reverse()
model: {
var buttons = [];
// HACK We want the actions to be right-aligned but Flow also reverses
var actionNames = (notificationItem.actionNames || []).reverse();
var actionLabels = (notificationItem.actionLabels || []).reverse();
for (var i = 0; i < actionNames.length; ++i) {
buttons.push({
actionName: actionNames[i],
label: actionLabels[i]
});
}
return buttons;
}
PlasmaComponents.ToolButton {
flat: false
text: notificationItem.actionLabels[actionRepeater.count - index - 1]
// why does it spit "cannot assign undefined to string" when a notification becomes expired?
text: modelData.label || ""
Layout.preferredWidth: minimumWidth
onClicked: notificationItem.actionInvoked(modelData)
onClicked: notificationItem.actionInvoked(modelData.actionName)
}
}
}
......
......@@ -31,6 +31,8 @@ import ".."
PlasmaCore.Dialog {
id: notificationPopup
property int popupWidth
property alias notificationType: notificationItem.notificationType
//readonly property bool isNotification: notificationType === NotificationManager.Notifications.NotificationType
//readonly property bool isJob: notificationType === NotificationManager.Notifications.JobType
......@@ -115,7 +117,7 @@ PlasmaCore.Dialog {
mainItem: MouseArea {
id: area
width: popupHandler.popupWidth
width: notificationPopup.popupWidth
height: notificationItem.implicitHeight
hoverEnabled: true
......@@ -124,6 +126,9 @@ PlasmaCore.Dialog {
onClicked: notificationPopup.defaultActionInvoked()
LayoutMirroring.enabled: Qt.application.layoutDirection === Qt.RightToLeft
LayoutMirroring.childrenInherit: true
Timer {
id: timer
interval: notificationPopup.effectiveTimeout
......
......@@ -31,45 +31,39 @@ 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: popupHandler
id: globals
// Some parts of the code rely on plasmoid.nativeInterface and since we're in a singleton here
// this is named "plasmoid", TODO fix?
property QtObject plasmoid: plasmoids[0]
// all notification plasmoids
property var plasmoids: []
// Listened to by "ago" label in NotificationHeader to update all of them in unison
signal timeChanged
// This heuristic tries to find a suitable plasmoid to follow when placing popups
function plasmoidScore(plasmoid) {
if (!plasmoid) {
return 0;
}
property bool inhibited: false
var score = 0;
// Reset the expire limiter so we don't get a flood of non-expired notifications
onInhibitedChanged: {
// 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;
}
// 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]
// Prefer plasmoids on primary screen
if (plasmoid.nativeInterface && plasmoid.nativeInterface.isPrimaryScreen(plasmoid.screenGeometry)) {
++score;
// 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();
}
return score;
}
// all notification plasmoids
property var plasmoids: []
property int popupLocation: {
switch (notificationSettings.popupPosition) {
// Auto-determine location based on plasmoid location
......@@ -81,10 +75,12 @@ QtObject {
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.AlignRight;
alignment |= Qt.application.layoutDirection === Qt.RightToLeft ? Qt.AlignLeft : Qt.AlignRight;
}
if (plasmoid.location === PlasmaCore.Types.TopEdge) {
alignment |= Qt.AlignTop;
......@@ -121,9 +117,46 @@ QtObject {
onPopupLocationChanged: Qt.callLater(positionPopups)
onScreenRectChanged: 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.push(plasmoid);
newPlasmoids.sort(function (a, b) {
var scoreA = plasmoidScore(a);
var scoreB = plasmoidScore(b);
......@@ -136,8 +169,23 @@ QtObject {
return 0;
}
});
globals.plasmoids = newPlasmoids;
}
function checkInhibition() {
globals.inhibited = Qt.binding(function() {
var inhibited = false;
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil;
if (!isNaN(inhibitedUntil.getTime())) {
console.log("INH", inhibitedUntil);
inhibited |= (new Date().getTime() < inhibitedUntil.getTime());
}
// TODO check app inhibition
popupHandler.plasmoids = newPlasmoids;
return inhibited;
});
}
function positionPopups() {
......@@ -195,7 +243,7 @@ QtObject {
}
property QtObject popupNotificationsModel: NotificationManager.Notifications {
limit: Math.ceil(popupHandler.screenRect.height / (theme.mSize(theme.defaultFont).height * 4))
limit: Math.ceil(globals.screenRect.height / (theme.mSize(theme.defaultFont).height * 4))
showExpired: false
showDismissed: false
blacklistedDesktopEntries: notificationSettings.popupBlacklistedApplications
......@@ -204,19 +252,48 @@ QtObject {
sortMode: NotificationManager.Notifications.SortByTypeAndUrgency
groupMode: NotificationManager.Notifications.GroupDisabled
urgencies: {
var urgencies = NotificationManager.Notifications.NormalUrgency | NotificationManager.Notifications.CriticalUrgency;
var urgencies = 0;