widgets/notifications: Cleanup, fix some visual issues, and add jobs

Note: There was a lot of whitespace that my IDE is now removing.

Fixes job notifications not being dismissable (#208), and imports an
implementation from workspace for the progress bar and actions.

Fixes notification contents not being clipped when being dragged
(https://invent.kde.org/teams/plasma-mobile/issues/-/issues/287)

Also fixes notification text being spread over multiple lines
unnecessarily
(https://invent.kde.org/teams/plasma-mobile/issues/-/issues/302).
This commit is contained in:
Devin Lin 2024-07-26 22:29:38 -04:00
parent b426a7d59e
commit 5d84e6e47d
9 changed files with 659 additions and 160 deletions

View file

@ -1,7 +1,7 @@
/*
* SPDX-FileCopyrightText: 2021 Devin Lin <devin@kde.org>
* SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik <kde@privat.broulik.de>
*
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
@ -18,25 +18,25 @@ import org.kde.coreaddons 1.0 as KCoreAddons
Item {
id: notificationItem
required property var notificationsModel
required property int notificationsModelType
/**
* Whether the notification is allowed to invoke any action, or if it should instead
* emit the runActionRequested(action) signal, containing the code to run.
*
*
* This is useful for cases like the lockscreen, where actions should only be run after
* the user logs in.
*/
property bool requestToInvoke: false
property var model
property int modelIndex
property P5Support.DataSource timeSource
readonly property int notificationType: model.type
readonly property bool inGroup: model.isInGroup || false
@ -54,7 +54,7 @@ Item {
readonly property string replyPlaceholderText: model.replyPlaceholderText || ""
readonly property string replySubmitButtonText: model.replySubmitButtonText || ""
readonly property string replySubmitButtonIconName: model.replySubmitButtonIconName || ""
// configure button on every single notifications is a bit overwhelming
readonly property bool configurable: !inGroup && model.configurable
@ -75,7 +75,7 @@ Item {
readonly property int jobError: model.jobError || 0
readonly property bool suspendable: !!model.suspendable
readonly property bool killable: !!model.killable
readonly property QtObject jobDetails: model.jobDetails || null
readonly property string configureActionLabel: model.configureActionLabel || ""
@ -97,13 +97,13 @@ Item {
}
return labels;
}
/**
* This signal is emitted and intended for the parent to make its own decision
* on whether to run the requested notification action.
*/
signal runActionRequested()
signal actionInvoked(string actionName)
signal replied(string text)
signal openUrl(string url)
@ -112,7 +112,7 @@ Item {
signal suspendJobClicked
signal resumeJobClicked
signal killJobClicked
function expire() {
if (model.resident) {
model.expired = true;
@ -132,17 +132,17 @@ Item {
notificationsModel.close(notificationsModel.index(modelIndex, 0));
}
}
// TODO call
function configure() {
notificationsModel.configure(notificationsModel.index(modelIndex, 0))
}
property var pendingAction: () => {}
function runPendingAction() {
pendingAction();
}
onActionInvoked: {
let action = () => {
if (notificationsModelType === NotificationsModelType.WatchedNotificationsModel) {
@ -153,14 +153,14 @@ Item {
}
} else if (notificationsModelType === NotificationsModelType.NotificationsModel) {
if (actionName === "default") {
notificationsModel.invokeDefaultAction(notificationsModel.index(modelIndex, 0), NotificationManager.Close); // notification closes
notificationsModel.invokeDefaultAction(notificationsModel.index(modelIndex, 0), NotificationManager.Close); // notification closes
} else {
notificationsModel.invokeAction(notificationsModel.index(modelIndex, 0), actionName, NotificationManager.Close); // notification closes
notificationsModel.invokeAction(notificationsModel.index(modelIndex, 0), actionName, NotificationManager.Close); // notification closes
}
}
expire();
}
if (notificationItem.requestToInvoke) {
pendingAction = action;
runActionRequested();
@ -168,13 +168,13 @@ Item {
action();
}
}
onOpenUrl: {
let action = () => {
Qt.openUrlExternally(url);
expire();
}
if (notificationItem.requestToInvoke) {
pendingAction = action;
runActionRequested();
@ -182,7 +182,7 @@ Item {
action();
}
}
onFileActionInvoked: {
let action = () => {
if (action.objectName === "movetotrash" || action.objectName === "deletefile") {
@ -191,7 +191,7 @@ Item {
expire();
}
}
if (notificationItem.requestToInvoke) {
pendingAction = action;
runActionRequested();
@ -199,10 +199,10 @@ Item {
action();
}
}
onSuspendJobClicked: {
let action = () => notificationsModel.suspendJob(notificationsModel.index(modelIndex, 0));
if (notificationItem.requestToInvoke) {
pendingAction = action;
runActionRequested();
@ -210,10 +210,10 @@ Item {
action();
}
}
onResumeJobClicked: {
let action = () => notificationsModel.resumeJob(notificationsModel.index(modelIndex, 0));
if (notificationItem.requestToInvoke) {
pendingAction = action;
runActionRequested();
@ -221,10 +221,10 @@ Item {
action();
}
}
onKillJobClicked: {
let action = () => notificationsModel.killJob(notificationsModel.index(modelIndex, 0));
if (notificationItem.requestToInvoke) {
pendingAction = action;
runActionRequested();

View file

@ -2,12 +2,12 @@
* SPDX-FileCopyrightText: 2021 Devin Lin <devin@kde.org>
* SPDX-FileCopyrightText: 2011 Marco Martin <notmart@gmail.com>
* SPDX-FileCopyrightText: 2014, 2019 Kai Uwe Broulik <kde@privat.broulik.de>
*
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
import QtQuick 2.15
import QtQuick.Window 2.2
import QtQuick
import QtQuick.Window
import org.kde.plasma.components 3.0 as PlasmaComponents

View file

@ -10,52 +10,54 @@ import org.kde.plasma.components 3.0 as PlasmaComponents
Item {
id: root
default property Item contentItem
property bool tapEnabled: false
property bool swipeGestureEnabled: false
property real dragOffset: 0
signal tapped()
signal dismissRequested()
signal configureClicked() // TODO implement settings button
onContentItemChanged: {
contentItem.parent = contentParent;
contentItem.anchors.fill = contentParent;
contentItem.anchors.margins = Kirigami.Units.largeSpacing;
contentParent.children.push(contentItem);
}
implicitHeight: contentParent.implicitHeight
NumberAnimation on dragOffset {
id: dragAnim
duration: Kirigami.Units.longDuration
easing.type: Easing.OutCubic
onFinished: {
if (to !== 0) {
root.dismissRequested();
}
}
}
// shadow
MultiEffect {
anchors.fill: mainCard
visible: Math.abs(dragOffset) !== root.width
source: mainCard
source: simpleShadow
blurMax: 16
shadowEnabled: true
shadowVerticalOffset: 1
shadowOpacity: 0.5
shadowColor: Qt.lighter(Kirigami.Theme.backgroundColor, 0.1)
shadowOpacity: 0.3
shadowColor: Qt.lighter(Kirigami.Theme.backgroundColor, 0.2)
}
// shadow
Rectangle {
id: simpleShadow
visible: Math.abs(dragOffset) !== root.width
anchors.fill: mainCard
anchors.leftMargin: -1
@ -74,12 +76,14 @@ Item {
anchors.right: parent.right
anchors.rightMargin: root.dragOffset < 0 ? -root.dragOffset : 0
anchors.top: parent.top
color: (root.tapEnabled && mouseArea.pressed) ? Qt.darker(Kirigami.Theme.backgroundColor, 1.1) : Kirigami.Theme.backgroundColor
radius: Kirigami.Units.smallSpacing
radius: Kirigami.Units.smallSpacing
implicitHeight: contentParent.implicitHeight
clip: true
// clip
layer.enabled: true
// ensure this is behind the content to not interfere
MouseArea {
id: mouseArea
@ -90,28 +94,28 @@ Item {
}
}
}
// content parent
Item {
id: contentParent
anchors.top: parent.top
anchors.left: root.dragOffset > 0 ? parent.left : undefined
anchors.right: root.dragOffset < 0 ? parent.right : undefined
width: root.width
implicitHeight: contentItem.implicitHeight + contentItem.anchors.topMargin + contentItem.anchors.bottomMargin
implicitHeight: contentItem.implicitHeight + contentItem.anchors.topMargin + contentItem.anchors.bottomMargin
}
}
DragHandler {
id: dragHandler
enabled: root.swipeGestureEnabled
yAxis.enabled: false
property real startDragOffset: 0
property real startPosition: 0
property bool startActive: false
onTranslationChanged: {
if (startActive) {
startDragOffset = root.dragOffset;
@ -120,11 +124,11 @@ Item {
}
root.dragOffset = startDragOffset + (translation.x - startPosition);
}
onActiveChanged: {
dragAnim.stop();
startActive = active;
if (!active) { // release event
let threshold = Kirigami.Units.gridUnit * 5; // drag threshold
if (root.dragOffset > threshold) {

View file

@ -0,0 +1,66 @@
/*
SPDX-FileCopyrightText: 2019 Kai Uwe Broulik <kde@privat.broulik.de>
SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
import QtQuick 2.8
import org.kde.plasma.extras 2.0 as PlasmaExtras
import org.kde.kquickcontrolsaddons 2.0 as KQCAddons
PlasmaExtras.Menu {
id: contextMenu
signal closed
property QtObject __clipboard: KQCAddons.Clipboard { }
// can be a Text or TextEdit
property Item target
property string link
onStatusChanged: {
if (status === PlasmaExtras.Menu.Closed) {
closed();
}
}
PlasmaExtras.MenuItem {
text: i18ndc("plasma_applet_org.kde.plasma.notifications", "@action:inmenu", "Copy Link Address")
icon: "edit-copy-symbolic"
onClicked: __clipboard.content = contextMenu.link
visible: contextMenu.link !== ""
}
PlasmaExtras.MenuItem {
separator: true
visible: contextMenu.link !== ""
}
PlasmaExtras.MenuItem {
text: i18ndc("plasma_applet_org.kde.plasma.notifications", "@action:inmenu", "Copy")
icon: "edit-copy-symbolic"
enabled: typeof target.selectionStart !== "undefined"
? target.selectionStart !== target.selectionEnd
: (target.text || "").length > 0
onClicked: {
if (typeof target.copy === "function") {
target.copy();
} else {
__clipboard.content = target.text;
}
}
}
PlasmaExtras.MenuItem {
id: selectAllAction
icon: "edit-select-all-symbolic"
text: i18ndc("plasma_applet_org.kde.plasma.notifications", "@action:inmenu", "Select All")
onClicked: target.selectAll()
visible: typeof target.selectAll === "function"
}
}

View file

@ -1,7 +1,7 @@
/*
* SPDX-FileCopyrightText: 2021 Devin Lin <devin@kde.org>
* SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik <kde@privat.broulik.de>
*
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
@ -20,21 +20,11 @@ import org.kde.coreaddons 1.0 as KCoreAddons
RowLayout {
id: notificationHeading
property int notificationType
property var applicationIconSource
property string applicationName
property string originName
property var time
property P5Support.DataSource timeSource
property int jobState
property QtObject jobDetails
property real timeout: 5000
property real remainingTime: 0
spacing: Kirigami.Units.smallSpacing
Layout.preferredHeight: Math.max(applicationNameLabel.implicitHeight, Kirigami.Units.iconSizes.small)

View file

@ -1,7 +1,7 @@
/*
* SPDX-FileCopyrightText: 2021 Devin Lin <devin@kde.org>
* SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik <kde@privat.broulik.de>
*
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
@ -23,7 +23,7 @@ import org.kde.coreaddons 1.0 as KCoreAddons
BaseNotificationItem {
id: notificationItem
implicitHeight: mainCard.implicitHeight + mainCard.anchors.topMargin + notificationHeading.height
// notification heading for groups with one element
NotificationGroupHeader {
id: notificationHeading
@ -40,15 +40,8 @@ BaseNotificationItem {
applicationName: notificationItem.applicationName
applicationIconSource: notificationItem.applicationIconSource
originName: notificationItem.originName
notificationType: notificationItem.notificationType
jobState: notificationItem.jobState
jobDetails: notificationItem.jobDetails
time: notificationItem.time
timeSource: notificationItem.timeSource
}
// notification
NotificationCard {
id: mainCard
@ -56,21 +49,21 @@ BaseNotificationItem {
anchors.top: notificationHeading.bottom
anchors.left: parent.left
anchors.right: parent.right
tapEnabled: notificationItem.hasDefaultAction
onTapped: notificationItem.actionInvoked("default");
swipeGestureEnabled: notificationItem.notificationType != NotificationManager.Notifications.JobType
swipeGestureEnabled: notificationItem.closable
onDismissRequested: notificationItem.close()
ColumnLayout {
id: column
spacing: 0
// notification summary row
RowLayout {
Layout.fillWidth: true
Layout.bottomMargin: Kirigami.Units.smallSpacing
// notification summary
PlasmaComponents.Label {
id: summaryLabel
@ -83,19 +76,19 @@ BaseNotificationItem {
visible: text !== ""
font.weight: Font.DemiBold
}
// notification timestamp
NotificationTimeText {
Layout.alignment: Qt.AlignRight | Qt.AlignTop
notificationType: notificationItem.notificationType
jobState: notificationItem.jobState
jobDetails: notificationItem.jobDetails
time: notificationItem.time
timeSource: notificationItem.timeSource
}
}
// notification contents
RowLayout {
Layout.fillWidth: true
@ -105,17 +98,11 @@ BaseNotificationItem {
NotificationBodyLabel {
id: bodyLabel
Layout.alignment: Qt.AlignTop | Qt.AlignLeft
Layout.fillWidth: true
// HACK RichText does not allow to specify link color and since LineEdit
// does not support StyledText, we have to inject some CSS to force the color,
// cf. QTBUG-81463 and to some extent QTBUG-80354
text: "<style>a { color: " + Kirigami.Theme.linkColor + "; }</style>" + notificationItem.body
Layout.preferredWidth: column.width - iconContainer.width - Kirigami.Units.smallSpacing
// Cannot do text !== "" because RichText adds some HTML tags even when empty
visible: notificationItem.body !== ""
text: notificationItem.body
}
// notification icon
Item {
id: iconContainer
@ -138,13 +125,41 @@ BaseNotificationItem {
}
}
}
// Job progress reporting
Loader {
id: jobLoader
Layout.fillWidth: true
Layout.preferredHeight: item ? item.implicitHeight : 0
active: notificationItem.notificationType === NotificationManager.Notifications.JobType
visible: active
sourceComponent: NotificationJobItem {
iconContainerItem: iconContainer
jobState: notificationItem.jobState
jobError: notificationItem.jobError
percentage: notificationItem.percentage
suspendable: notificationItem.suspendable
killable: notificationItem.killable
jobDetails: notificationItem.jobDetails
onSuspendJobClicked: notificationItem.suspendJobClicked()
onResumeJobClicked: notificationItem.resumeJobClicked()
onKillJobClicked: notificationItem.killJobClicked()
onOpenUrl: notificationItem.openUrl(url)
onFileActionInvoked: notificationItem.fileActionInvoked(action)
}
}
// notification actions
NotificationFooterActions {
Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
notification: notificationItem
}
// thumbnails
Loader {
id: thumbnailStripLoader

View file

@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: 2019 Kai Uwe Broulik <kde@privat.broulik.de>
// SPDX-FileCopyrightText: 2024 Devin Lin <devin@kde.org>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick 2.8
import QtQuick.Layouts 1.1
import org.kde.plasma.extras 2.0 as PlasmaExtras
import org.kde.kirigami 2.20 as Kirigami
import org.kde.coreaddons 1.0 as KCoreAddons
GridLayout {
id: detailsGrid
property QtObject jobDetails
columns: 2
rowSpacing: Math.round(Kirigami.Units.smallSpacing / 2)
columnSpacing: Kirigami.Units.smallSpacing
// once you use Layout.column/Layout.row *all* of the items in the Layout have to use them
Repeater {
model: [1, 2]
PlasmaExtras.DescriptiveLabel {
Layout.column: 0
Layout.row: index
Layout.alignment: Qt.AlignTop | Qt.AlignRight
text: jobDetails["descriptionLabel" + modelData] && jobDetails["descriptionValue" + modelData]
? i18ndc("plasma_applet_org.kde.plasma.notifications", "Row description, e.g. Source", "%1:", jobDetails["descriptionLabel" + modelData]) : ""
font: Kirigami.Theme.smallFont
textFormat: Text.PlainText
visible: text !== ""
}
}
Repeater {
model: [1, 2]
PlasmaExtras.DescriptiveLabel {
id: descriptionValueLabel
Layout.column: 1
Layout.row: index
Layout.fillWidth: true
font: Kirigami.Theme.smallFont
elide: Text.ElideMiddle
textFormat: Text.PlainText
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
verticalAlignment: Text.AlignTop
maximumLineCount: 5
visible: text !== ""
// Only let the label grow, never shrink, to avoid repeatedly resizing the dialog when copying many files
onImplicitHeightChanged: {
if (implicitHeight > Layout.preferredHeight) {
Layout.preferredHeight = implicitHeight;
}
}
Component.onCompleted: bindText()
function bindText() {
text = Qt.binding(function() {
return jobDetails["descriptionValue" + modelData] || "";
});
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
onPressed: mouse => {
// break binding so it doesn't update while the menu is opened
descriptionValueLabel.text = descriptionValueLabel.text;
descriptionValueMenu.open(mouse.x, mouse.y)
}
}
NotificationEditContextMenu {
id: descriptionValueMenu
target: descriptionValueLabel
// defer re-binding until after the "Copy" action in the menu has triggered
onClosed: Qt.callLater(descriptionValueLabel.bindText)
}
}
}
Repeater {
model: ["Bytes", "Files", "Directories", "Items"]
PlasmaExtras.DescriptiveLabel {
Layout.column: 1
Layout.row: 2 + index
Layout.fillWidth: true
text: {
var processed = jobDetails["processed" + modelData];
var total = jobDetails["total" + modelData];
if (processed > 0 || total > 1) {
if (processed > 0 && total > 0 && processed <= total) {
switch(modelData) {
case "Bytes":
return i18ndc("plasma_applet_org.kde.plasma.notifications", "How many bytes have been copied", "%2 of %1",
KCoreAddons.Format.formatByteSize(total),
KCoreAddons.Format.formatByteSize(processed))
case "Files":
return i18ndcp("plasma_applet_org.kde.plasma.notifications", "How many files have been copied", "%2 of %1 file", "%2 of %1 files",
total, processed);
case "Directories":
return i18ndcp("plasma_applet_org.kde.plasma.notifications", "How many dirs have been copied", "%2 of %1 folder", "%2 of %1 folders",
total, processed);
case "Items":
return i18ndcp("plasma_applet_org.kde.plasma.notifications", "How many items (that includes files and dirs) have been copied", "%2 of %1 item", "%2 of %1 items",
total, processed);
}
} else {
switch(modelData) {
case "Bytes":
return KCoreAddons.Format.formatByteSize(processed || total)
case "Files":
return i18ndp("plasma_applet_org.kde.plasma.notifications", "%1 file", "%1 files", (processed || total));
case "Directories":
return i18ndp("plasma_applet_org.kde.plasma.notifications", "%1 folder", "%1 folders", (processed || total));
case "Items":
return i18ndp("plasma_applet_org.kde.plasma.notifications", "%1 item", "%1 items", (processed || total));
}
}
}
return "";
}
font: Kirigami.Theme.smallFont
textFormat: Text.PlainText
visible: text !== ""
}
}
PlasmaExtras.DescriptiveLabel {
Layout.column: 1
Layout.row: 2 + 4
Layout.fillWidth: true
text: jobDetails.speed > 0 ? i18ndc("plasma_applet_org.kde.plasma.notifications", "Bytes per second", "%1/s",
KCoreAddons.Format.formatByteSize(jobDetails.speed)) : ""
font: Kirigami.Theme.smallFont
textFormat: Text.PlainText
visible: text !== ""
}
}

View file

@ -0,0 +1,277 @@
// SPDX-FileCopyrightText: 2019 Kai Uwe Broulik <kde@privat.broulik.de>
// SPDX-FileCopyrightText: 2024 Devin Lin <devin@kde.org>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick 2.8
import QtQuick.Window 2.2
import QtQuick.Layouts 1.1
import QtQml 2.15
import org.kde.plasma.components 3.0 as PlasmaComponents3
import org.kde.kirigami 2.20 as Kirigami
import org.kde.notificationmanager as NotificationManager
import org.kde.plasma.private.notifications 2.0 as Notifications
ColumnLayout {
id: jobItem
property int jobState
property int jobError
property alias percentage: progressBar.value
property alias suspendable: suspendButton.visible
property alias killable: killButton.visible
property QtObject jobDetails
readonly property int totalFiles: jobItem.jobDetails && jobItem.jobDetails.totalFiles || 0
readonly property var url: {
if (jobItem.jobState !== NotificationManager.Notifications.JobStateStopped
|| jobItem.jobError) {
return null;
}
// For a single file show actions for it
// Otherwise the destination folder all of them were copied into
const url = totalFiles === 1 ? jobItem.jobDetails.descriptionUrl
: jobItem.jobDetails.destUrl;
// Don't offer opening files in Trash
if (url && url.toString().startsWith("trash:")) {
return null;
}
return url;
}
property alias iconContainerItem: busyIndicatorContainer.parent
readonly property alias menuOpen: otherFileActionsMenu.visible
signal suspendJobClicked
signal resumeJobClicked
signal killJobClicked
signal openUrl(string url)
signal fileActionInvoked(QtObject action)
spacing: Kirigami.Units.smallSpacing
Notifications.FileInfo {
id: fileInfo
url: jobItem.totalFiles === 1 && jobItem.url ? jobItem.url : ""
}
Item {
id: busyIndicatorContainer
width: parent ? parent.width : 0
height: parent ? parent.height : 0
PlasmaComponents3.BusyIndicator {
id: busyIndicator
anchors.centerIn: parent
running: fileInfo.busy && !delayBusyTimer.running
visible: running
// Avoid briefly flashing the busy indicator
Timer {
id: delayBusyTimer
interval: 500
repeat: false
running: fileInfo.busy
}
}
}
RowLayout {
id: progressRow
Layout.fillWidth: true
// Even when indeterminate, we want to reserve the height for the text, otherwise it's too tightly spaced
Layout.minimumHeight: progressText.implicitHeight
// We want largeSpacing between the progress bar and the label
spacing: Kirigami.Units.largeSpacing
PlasmaComponents3.ProgressBar {
id: progressBar
Layout.fillWidth: true
from: 0
to: 100
// TODO do we actually need the window visible check? perhaps I do because it can be in popup or expanded plasmoid
indeterminate: visible && Window.window && Window.window.visible && percentage < 1
&& jobItem.jobState === NotificationManager.Notifications.JobStateRunning
// is this too annoying?
&& (jobItem.jobDetails.processedBytes === 0 || jobItem.jobDetails.totalBytes === 0)
&& jobItem.jobDetails.processedFiles === 0
//&& jobItem.jobDetails.processedDirectories === 0
}
PlasmaComponents3.Label {
id: progressText
visible: !progressBar.indeterminate
// the || "0" is a workaround for the fact that 0 as number is falsey, and is wrongly considered a missing argument
// BUG: 451807
text: i18ndc("plasma_applet_org.kde.plasma.notifications", "Percentage of a job", "%1%", jobItem.percentage || "0")
textFormat: Text.PlainText
}
}
RowLayout {
id: jobActionsRow
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
PlasmaComponents3.Button {
id: expandButton
icon.name: checked ? "collapse-symbolic" : "expand-symbolic"
text: i18ndc("plasma_applet_org.kde.plasma.notifications", "Hides/expands item details", "Details")
checkable: true
enabled: jobItem.jobDetails && jobItem.jobDetails.hasDetails
}
Item { Layout.fillWidth: true }
PlasmaComponents3.Button {
id: suspendButton
icon.name: "media-playback-pause-symbolic"
text: i18ndc("plasma_applet_org.kde.plasma.notifications", "Pause running job", "Pause")
onClicked: jobItem.jobState === NotificationManager.Notifications.JobStateSuspended ? jobItem.resumeJobClicked()
: jobItem.suspendJobClicked()
}
PlasmaComponents3.Button {
id: killButton
icon.name: "dialog-cancel-symbolic"
text: i18ndc("plasma_applet_org.kde.plasma.notifications", "Cancel running job", "Cancel")
onClicked: jobItem.killJobClicked()
}
}
Loader {
Layout.fillWidth: true
Layout.preferredHeight: item ? item.implicitHeight : 0
active: expandButton.checked
// Loader doesn't reset its height when unloaded, just hide it altogether
visible: active
sourceComponent: NotificationJobDetails {
jobDetails: jobItem.jobDetails
}
}
Row {
id: fileActionsRow
Layout.fillWidth: true
spacing: Kirigami.Units.smallSpacing
// We want the actions to be right-aligned but Row also reverses
// the order of items, so we put them in reverse order
layoutDirection: Qt.RightToLeft
visible: jobItem.url && jobItem.url.toString() !== "" && !fileInfo.error
PlasmaComponents3.Button {
id: otherFileActionsButton
height: Math.max(implicitHeight, openButton.implicitHeight)
icon.name: "application-menu-symbolic"
checkable: true
text: openButton.visible ? "" : Accessible.name
Accessible.name: i18nd("plasma_applet_org.kde.plasma.notifications", "More Options…")
onPressedChanged: {
if (pressed) {
checked = Qt.binding(function() {
return otherFileActionsMenu.visible;
});
otherFileActionsMenu.visualParent = this;
// -1 tells it to "align bottom left of visualParent (this)"
otherFileActionsMenu.open(-1, -1);
}
}
PlasmaComponents3.ToolTip {
text: parent.Accessible.name
enabled: parent.text === ""
}
Notifications.FileMenu {
id: otherFileActionsMenu
url: jobItem.url || ""
onActionTriggered: jobItem.fileActionInvoked(action)
}
}
PlasmaComponents3.Button {
id: openButton
width: Math.min(implicitWidth, jobItem.width - otherFileActionsButton.width - fileActionsRow.spacing)
height: Math.max(implicitHeight, otherFileActionsButton.implicitHeight)
text: i18nd("plasma_applet_org.kde.plasma.notifications", "Open")
onClicked: jobItem.openUrl(jobItem.url)
states: [
State {
when: jobItem.jobDetails && jobItem.jobDetails.totalFiles !== 1
PropertyChanges {
target: openButton
text: i18nd("plasma_applet_org.kde.plasma.notifications", "Open Containing Folder")
icon.name: "folder-open-symbolic"
}
},
State {
when: fileInfo.openAction
PropertyChanges {
target: openButton
text: fileInfo.openAction.text
icon.name: fileInfo.openActionIconName
visible: fileInfo.openAction.enabled
onClicked: {
fileInfo.openAction.trigger();
jobItem.fileActionInvoked(fileInfo.openAction);
}
}
}
]
}
}
states: [
State {
when: jobItem.jobState === NotificationManager.Notifications.JobStateRunning
PropertyChanges {
target: suspendButton
// Explicitly set it to false so it unchecks when pausing from applet
// and then the job unpauses programmatically elsewhere.
checked: false
}
},
State {
when: jobItem.jobState === NotificationManager.Notifications.JobStateSuspended
PropertyChanges {
target: suspendButton
checked: true
}
PropertyChanges {
target: progressBar
enabled: false
}
},
State {
when: jobItem.jobState === NotificationManager.Notifications.JobStateStopped
PropertyChanges {
target: progressRow
visible: false
}
PropertyChanges {
target: jobActionsRow
visible: false
}
PropertyChanges {
target: expandButton
checked: false
}
}
]
}

View file

@ -26,69 +26,69 @@ import org.kde.notificationmanager as NotificationManager
*/
Item {
id: root
/**
* The notification model for the widget.
*/
property var historyModel
/**
* The type of notification model used for the widget.
*/
property int historyModelType
/**
* The notification model settings for the widget.
*/
property var notificationSettings
/**
* Whether invoking notification actions requires authentiation of some sort.
*
*
* If set to true, any attempted invoking will trigger the unlockRequested() signal.
* Any consumers can then call the runPendingAction() function if authenticated to proceed
* executing the notification action.
*/
property bool actionsRequireUnlock: false
/**
* Whether the widget has notifications.
*/
readonly property bool hasNotifications: list.count > 0
readonly property bool doNotDisturbModeEnabled: !isNaN(notificationSettings.notificationsInhibitedUntil)
enum ModelType {
NotificationsModel, // used in the logged-in shell
WatchedNotificationsModel // used on the lockscreen
}
/**
* Signal emitted when authentication is requested for an action.
* Listeners should call runPendingAction() if authentication is successful.
*
*
* Only emitted if actionsRequireUnlock is enabled.
*/
signal unlockRequested()
/**
* Emitted when the background is clicked (not a notification or other element).
*/
signal backgroundClicked()
/**
* Run pending action that was pending for authentication when unlockRequested() was emitted.
*/
function runPendingAction() {
list.pendingNotificationWithAction.runPendingAction();
}
/**
* Clears the history of the notification model.
*/
function clearHistory() {
historyModel.clear(NotificationManager.Notifications.ClearExpired);
if (historyModel.count === 0) {
backgroundClicked();
}
@ -102,15 +102,15 @@ Item {
notificationSettings.defaults();
} else {
var until = new Date();
until.setFullYear(until.getFullYear() + 1);
notificationSettings.notificationsInhibitedUntil = until;
}
notificationSettings.save();
}
/**
* Open the system notification settings.
*/
@ -125,7 +125,7 @@ Item {
interval: 60000 // 1 min
intervalAlignment: P5Support.Types.AlignToMinute
}
// implement background clicking signal
MouseArea {
anchors.fill: parent
@ -136,40 +136,40 @@ Item {
ListView {
id: list
model: historyModel
clip: true
currentIndex: 0
property var pendingNotificationWithAction
readonly property int animationDuration: ShellSettings.Settings.animationsEnabled ? Kirigami.Units.longDuration : 0
// If a screen overflow occurs, fix height in order to maintain tool buttons in place.
readonly property bool listOverflowing: contentItem.childrenRect.height + toolButtons.height + spacing >= root.height
bottomMargin: spacing
height: count === 0 ? 0 : (listOverflowing ? root.height - toolButtons.height : contentItem.childrenRect.height + bottomMargin)
anchors {
top: parent.top
left: parent.left
right: parent.right
}
boundsBehavior: Flickable.StopAtBounds
spacing: Kirigami.Units.largeSpacing
// TODO keyboard focus
highlightMoveDuration: 0
highlightResizeDuration: 0
highlight: Item {}
highlight: Item {}
section {
property: "isGroup"
criteria: ViewSection.FullString
}
PlasmaExtras.PlaceholderMessage {
anchors.centerIn: parent
width: parent.width - (Kirigami.Units.gridUnit * 4)
@ -187,7 +187,7 @@ Item {
visible: currentOwner && currentOwner.vendor && currentOwner.name
}
}
// Run every time an item is visually added to the list, thus when `Show n more` button is clicked as well.
add: Transition {
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: list.animationDuration }
@ -196,7 +196,7 @@ Item {
displaced: Transition {
NumberAnimation { properties: "y"; duration: list.animationDuration }
}
function isRowExpanded(row) {
var idx = historyModel.index(row, 0);
return historyModel.data(idx, NotificationManager.Notifications.IsGroupExpandedRole);
@ -219,90 +219,89 @@ Item {
}
}
}
// Instantly re-align items after group expansion.
forceLayout();
}
delegate: Loader {
id: delegateLoader
anchors {
left: parent ? parent.left : undefined
leftMargin: Kirigami.Units.gridUnit
right: parent ? parent.right : undefined
rightMargin: Kirigami.Units.gridUnit
}
height: model.isGroup ? groupDelegate.height : notificationDelegate.height
sourceComponent: model.isGroup ? groupDelegate : notificationDelegate
asynchronous: true
required property var model
required property int index
// We have to do this here in order to control the animation before the item is completely removed
ListView.onRemove: SequentialAnimation {
PropertyAction { target: delegateLoader; property: "ListView.delayRemove"; value: true }
NumberAnimation { target: delegateLoader; property: "opacity"; to: 0.0; duration: list.animationDuration }
PropertyAction { target: delegateLoader; property: "ListView.delayRemove"; value: false }
}
Component {
id: groupDelegate
NotificationGroupHeader {
NotificationGroupHeader {
applicationName: model.applicationName
applicationIconSource: model.applicationIconName
originName: model.originName || ""
timeSource: timeDataSource
}
}
Component {
id: notificationDelegate
Column {
spacing: Kirigami.Units.smallSpacing
height: notificationItem.height + showMoreLoader.height
NotificationItem {
id: notificationItem
width: parent.width
height: implicitHeight
model: delegateLoader.model
modelIndex: delegateLoader.index
notificationsModel: root.historyModel
notificationsModelType: root.historyModelType
timeSource: timeDataSource
requestToInvoke: root.actionsRequireUnlock
onRunActionRequested: {
list.pendingNotificationWithAction = notificationItem;
root.unlockRequested();
}
}
// Every item has got an instance of this loader, but it becomes active only for last ones that take place in big enough groups.
Loader {
id: showMoreLoader
height: visible ? implicitHeight : 0
opacity: 0.0
visible: active
asynchronous: true
active: {
// if we have the WatchedNotificationsModel, we don't have notification grouping support
if (typeof model.groupChildrenCount === 'undefined')
return false;
return (model.groupChildrenCount > model.expandedGroupChildrenCount || model.isGroupExpanded)
&& delegateLoader.ListView.nextSection != delegateLoader.ListView.section;
}
// state + transition: animates the item when it becomes visible. Fade off is handled by above ListView.onRemove.
states: State {
name: "VISIBLE"
@ -316,7 +315,7 @@ Item {
NumberAnimation { properties: "opacity"; duration: list.animationDuration }
}
}
sourceComponent: PlasmaComponents3.ToolButton {
icon.name: model.isGroupExpanded ? "arrow-up" : "arrow-down"
text: model.isGroupExpanded ? i18n("Show Fewer")
@ -331,31 +330,31 @@ Item {
}
}
}
Item {
id: toolButtons
height: visible ? spacer.height + toolLayout.height + toolLayout.anchors.topMargin + toolLayout.anchors.bottomMargin : 0
// do not show on lockscreen
visible: !root.actionsRequireUnlock
anchors {
top: list.bottom
left: parent.left
right: parent.right
}
Rectangle {
id: spacer
anchors.left: parent.left
anchors.right: parent.right
visible: list.listOverflowing
height: 1
height: 1
opacity: 0.25
color: Kirigami.Theme.textColor
}
RowLayout {
id: toolLayout
@ -368,17 +367,17 @@ Item {
topMargin: list.spacing
bottomMargin: list.spacing
}
PlasmaComponents3.ToolButton {
id: clearButton
Layout.alignment: Qt.AlignCenter
visible: hasNotifications
font.bold: true
font.pointSize: Kirigami.Theme.smallFont.pointSize
icon.name: "edit-clear-history"
text: i18n("Clear All Notifications")
onClicked: clearHistory()