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

View file

@ -2,12 +2,12 @@
* SPDX-FileCopyrightText: 2021 Devin Lin <devin@kde.org> * SPDX-FileCopyrightText: 2021 Devin Lin <devin@kde.org>
* SPDX-FileCopyrightText: 2011 Marco Martin <notmart@gmail.com> * SPDX-FileCopyrightText: 2011 Marco Martin <notmart@gmail.com>
* SPDX-FileCopyrightText: 2014, 2019 Kai Uwe Broulik <kde@privat.broulik.de> * 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 * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/ */
import QtQuick 2.15 import QtQuick
import QtQuick.Window 2.2 import QtQuick.Window
import org.kde.plasma.components 3.0 as PlasmaComponents 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 { Item {
id: root id: root
default property Item contentItem default property Item contentItem
property bool tapEnabled: false property bool tapEnabled: false
property bool swipeGestureEnabled: false property bool swipeGestureEnabled: false
property real dragOffset: 0 property real dragOffset: 0
signal tapped() signal tapped()
signal dismissRequested() signal dismissRequested()
signal configureClicked() // TODO implement settings button signal configureClicked() // TODO implement settings button
onContentItemChanged: { onContentItemChanged: {
contentItem.parent = contentParent; contentItem.parent = contentParent;
contentItem.anchors.fill = contentParent; contentItem.anchors.fill = contentParent;
contentItem.anchors.margins = Kirigami.Units.largeSpacing; contentItem.anchors.margins = Kirigami.Units.largeSpacing;
contentParent.children.push(contentItem); contentParent.children.push(contentItem);
} }
implicitHeight: contentParent.implicitHeight implicitHeight: contentParent.implicitHeight
NumberAnimation on dragOffset { NumberAnimation on dragOffset {
id: dragAnim id: dragAnim
duration: Kirigami.Units.longDuration duration: Kirigami.Units.longDuration
easing.type: Easing.OutCubic
onFinished: { onFinished: {
if (to !== 0) { if (to !== 0) {
root.dismissRequested(); root.dismissRequested();
} }
} }
} }
// shadow // shadow
MultiEffect { MultiEffect {
anchors.fill: mainCard anchors.fill: mainCard
visible: Math.abs(dragOffset) !== root.width visible: Math.abs(dragOffset) !== root.width
source: mainCard source: simpleShadow
blurMax: 16 blurMax: 16
shadowEnabled: true shadowEnabled: true
shadowVerticalOffset: 1 shadowVerticalOffset: 1
shadowOpacity: 0.5 shadowOpacity: 0.3
shadowColor: Qt.lighter(Kirigami.Theme.backgroundColor, 0.1) shadowColor: Qt.lighter(Kirigami.Theme.backgroundColor, 0.2)
} }
// shadow // shadow
Rectangle { Rectangle {
id: simpleShadow
visible: Math.abs(dragOffset) !== root.width visible: Math.abs(dragOffset) !== root.width
anchors.fill: mainCard anchors.fill: mainCard
anchors.leftMargin: -1 anchors.leftMargin: -1
@ -74,12 +76,14 @@ Item {
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: root.dragOffset < 0 ? -root.dragOffset : 0 anchors.rightMargin: root.dragOffset < 0 ? -root.dragOffset : 0
anchors.top: parent.top anchors.top: parent.top
color: (root.tapEnabled && mouseArea.pressed) ? Qt.darker(Kirigami.Theme.backgroundColor, 1.1) : Kirigami.Theme.backgroundColor 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 implicitHeight: contentParent.implicitHeight
clip: true
// clip
layer.enabled: true
// ensure this is behind the content to not interfere // ensure this is behind the content to not interfere
MouseArea { MouseArea {
id: mouseArea id: mouseArea
@ -90,28 +94,28 @@ Item {
} }
} }
} }
// content parent // content parent
Item { Item {
id: contentParent id: contentParent
anchors.top: parent.top anchors.top: parent.top
anchors.left: root.dragOffset > 0 ? parent.left : undefined anchors.left: root.dragOffset > 0 ? parent.left : undefined
anchors.right: root.dragOffset < 0 ? parent.right : undefined anchors.right: root.dragOffset < 0 ? parent.right : undefined
width: root.width width: root.width
implicitHeight: contentItem.implicitHeight + contentItem.anchors.topMargin + contentItem.anchors.bottomMargin implicitHeight: contentItem.implicitHeight + contentItem.anchors.topMargin + contentItem.anchors.bottomMargin
} }
} }
DragHandler { DragHandler {
id: dragHandler id: dragHandler
enabled: root.swipeGestureEnabled enabled: root.swipeGestureEnabled
yAxis.enabled: false yAxis.enabled: false
property real startDragOffset: 0 property real startDragOffset: 0
property real startPosition: 0 property real startPosition: 0
property bool startActive: false property bool startActive: false
onTranslationChanged: { onTranslationChanged: {
if (startActive) { if (startActive) {
startDragOffset = root.dragOffset; startDragOffset = root.dragOffset;
@ -120,11 +124,11 @@ Item {
} }
root.dragOffset = startDragOffset + (translation.x - startPosition); root.dragOffset = startDragOffset + (translation.x - startPosition);
} }
onActiveChanged: { onActiveChanged: {
dragAnim.stop(); dragAnim.stop();
startActive = active; startActive = active;
if (!active) { // release event if (!active) { // release event
let threshold = Kirigami.Units.gridUnit * 5; // drag threshold let threshold = Kirigami.Units.gridUnit * 5; // drag threshold
if (root.dragOffset > 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: 2021 Devin Lin <devin@kde.org>
* SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik <kde@privat.broulik.de> * 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 * 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 { RowLayout {
id: notificationHeading id: notificationHeading
property int notificationType
property var applicationIconSource property var applicationIconSource
property string applicationName property string applicationName
property string originName 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 spacing: Kirigami.Units.smallSpacing
Layout.preferredHeight: Math.max(applicationNameLabel.implicitHeight, Kirigami.Units.iconSizes.small) 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: 2021 Devin Lin <devin@kde.org>
* SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik <kde@privat.broulik.de> * 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 * 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 { BaseNotificationItem {
id: notificationItem id: notificationItem
implicitHeight: mainCard.implicitHeight + mainCard.anchors.topMargin + notificationHeading.height implicitHeight: mainCard.implicitHeight + mainCard.anchors.topMargin + notificationHeading.height
// notification heading for groups with one element // notification heading for groups with one element
NotificationGroupHeader { NotificationGroupHeader {
id: notificationHeading id: notificationHeading
@ -40,15 +40,8 @@ BaseNotificationItem {
applicationName: notificationItem.applicationName applicationName: notificationItem.applicationName
applicationIconSource: notificationItem.applicationIconSource applicationIconSource: notificationItem.applicationIconSource
originName: notificationItem.originName originName: notificationItem.originName
notificationType: notificationItem.notificationType
jobState: notificationItem.jobState
jobDetails: notificationItem.jobDetails
time: notificationItem.time
timeSource: notificationItem.timeSource
} }
// notification // notification
NotificationCard { NotificationCard {
id: mainCard id: mainCard
@ -56,21 +49,21 @@ BaseNotificationItem {
anchors.top: notificationHeading.bottom anchors.top: notificationHeading.bottom
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
tapEnabled: notificationItem.hasDefaultAction tapEnabled: notificationItem.hasDefaultAction
onTapped: notificationItem.actionInvoked("default"); onTapped: notificationItem.actionInvoked("default");
swipeGestureEnabled: notificationItem.notificationType != NotificationManager.Notifications.JobType swipeGestureEnabled: notificationItem.closable
onDismissRequested: notificationItem.close() onDismissRequested: notificationItem.close()
ColumnLayout { ColumnLayout {
id: column id: column
spacing: 0 spacing: 0
// notification summary row // notification summary row
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.bottomMargin: Kirigami.Units.smallSpacing Layout.bottomMargin: Kirigami.Units.smallSpacing
// notification summary // notification summary
PlasmaComponents.Label { PlasmaComponents.Label {
id: summaryLabel id: summaryLabel
@ -83,19 +76,19 @@ BaseNotificationItem {
visible: text !== "" visible: text !== ""
font.weight: Font.DemiBold font.weight: Font.DemiBold
} }
// notification timestamp // notification timestamp
NotificationTimeText { NotificationTimeText {
Layout.alignment: Qt.AlignRight | Qt.AlignTop Layout.alignment: Qt.AlignRight | Qt.AlignTop
notificationType: notificationItem.notificationType notificationType: notificationItem.notificationType
jobState: notificationItem.jobState jobState: notificationItem.jobState
jobDetails: notificationItem.jobDetails jobDetails: notificationItem.jobDetails
time: notificationItem.time time: notificationItem.time
timeSource: notificationItem.timeSource timeSource: notificationItem.timeSource
} }
} }
// notification contents // notification contents
RowLayout { RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
@ -105,17 +98,11 @@ BaseNotificationItem {
NotificationBodyLabel { NotificationBodyLabel {
id: bodyLabel id: bodyLabel
Layout.alignment: Qt.AlignTop | Qt.AlignLeft Layout.alignment: Qt.AlignTop | Qt.AlignLeft
Layout.fillWidth: true Layout.preferredWidth: column.width - iconContainer.width - Kirigami.Units.smallSpacing
// 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
// Cannot do text !== "" because RichText adds some HTML tags even when empty text: notificationItem.body
visible: notificationItem.body !== ""
} }
// notification icon // notification icon
Item { Item {
id: iconContainer 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 // notification actions
NotificationFooterActions { NotificationFooterActions {
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: Kirigami.Units.smallSpacing
notification: notificationItem notification: notificationItem
} }
// thumbnails // thumbnails
Loader { Loader {
id: thumbnailStripLoader 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 { Item {
id: root id: root
/** /**
* The notification model for the widget. * The notification model for the widget.
*/ */
property var historyModel property var historyModel
/** /**
* The type of notification model used for the widget. * The type of notification model used for the widget.
*/ */
property int historyModelType property int historyModelType
/** /**
* The notification model settings for the widget. * The notification model settings for the widget.
*/ */
property var notificationSettings property var notificationSettings
/** /**
* Whether invoking notification actions requires authentiation of some sort. * Whether invoking notification actions requires authentiation of some sort.
* *
* If set to true, any attempted invoking will trigger the unlockRequested() signal. * If set to true, any attempted invoking will trigger the unlockRequested() signal.
* Any consumers can then call the runPendingAction() function if authenticated to proceed * Any consumers can then call the runPendingAction() function if authenticated to proceed
* executing the notification action. * executing the notification action.
*/ */
property bool actionsRequireUnlock: false property bool actionsRequireUnlock: false
/** /**
* Whether the widget has notifications. * Whether the widget has notifications.
*/ */
readonly property bool hasNotifications: list.count > 0 readonly property bool hasNotifications: list.count > 0
readonly property bool doNotDisturbModeEnabled: !isNaN(notificationSettings.notificationsInhibitedUntil) readonly property bool doNotDisturbModeEnabled: !isNaN(notificationSettings.notificationsInhibitedUntil)
enum ModelType { enum ModelType {
NotificationsModel, // used in the logged-in shell NotificationsModel, // used in the logged-in shell
WatchedNotificationsModel // used on the lockscreen WatchedNotificationsModel // used on the lockscreen
} }
/** /**
* Signal emitted when authentication is requested for an action. * Signal emitted when authentication is requested for an action.
* Listeners should call runPendingAction() if authentication is successful. * Listeners should call runPendingAction() if authentication is successful.
* *
* Only emitted if actionsRequireUnlock is enabled. * Only emitted if actionsRequireUnlock is enabled.
*/ */
signal unlockRequested() signal unlockRequested()
/** /**
* Emitted when the background is clicked (not a notification or other element). * Emitted when the background is clicked (not a notification or other element).
*/ */
signal backgroundClicked() signal backgroundClicked()
/** /**
* Run pending action that was pending for authentication when unlockRequested() was emitted. * Run pending action that was pending for authentication when unlockRequested() was emitted.
*/ */
function runPendingAction() { function runPendingAction() {
list.pendingNotificationWithAction.runPendingAction(); list.pendingNotificationWithAction.runPendingAction();
} }
/** /**
* Clears the history of the notification model. * Clears the history of the notification model.
*/ */
function clearHistory() { function clearHistory() {
historyModel.clear(NotificationManager.Notifications.ClearExpired); historyModel.clear(NotificationManager.Notifications.ClearExpired);
if (historyModel.count === 0) { if (historyModel.count === 0) {
backgroundClicked(); backgroundClicked();
} }
@ -102,15 +102,15 @@ Item {
notificationSettings.defaults(); notificationSettings.defaults();
} else { } else {
var until = new Date(); var until = new Date();
until.setFullYear(until.getFullYear() + 1); until.setFullYear(until.getFullYear() + 1);
notificationSettings.notificationsInhibitedUntil = until; notificationSettings.notificationsInhibitedUntil = until;
} }
notificationSettings.save(); notificationSettings.save();
} }
/** /**
* Open the system notification settings. * Open the system notification settings.
*/ */
@ -125,7 +125,7 @@ Item {
interval: 60000 // 1 min interval: 60000 // 1 min
intervalAlignment: P5Support.Types.AlignToMinute intervalAlignment: P5Support.Types.AlignToMinute
} }
// implement background clicking signal // implement background clicking signal
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
@ -136,40 +136,40 @@ Item {
ListView { ListView {
id: list id: list
model: historyModel model: historyModel
clip: true clip: true
currentIndex: 0 currentIndex: 0
property var pendingNotificationWithAction property var pendingNotificationWithAction
readonly property int animationDuration: ShellSettings.Settings.animationsEnabled ? Kirigami.Units.longDuration : 0 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. // 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 readonly property bool listOverflowing: contentItem.childrenRect.height + toolButtons.height + spacing >= root.height
bottomMargin: spacing bottomMargin: spacing
height: count === 0 ? 0 : (listOverflowing ? root.height - toolButtons.height : contentItem.childrenRect.height + bottomMargin) height: count === 0 ? 0 : (listOverflowing ? root.height - toolButtons.height : contentItem.childrenRect.height + bottomMargin)
anchors { anchors {
top: parent.top top: parent.top
left: parent.left left: parent.left
right: parent.right right: parent.right
} }
boundsBehavior: Flickable.StopAtBounds boundsBehavior: Flickable.StopAtBounds
spacing: Kirigami.Units.largeSpacing spacing: Kirigami.Units.largeSpacing
// TODO keyboard focus // TODO keyboard focus
highlightMoveDuration: 0 highlightMoveDuration: 0
highlightResizeDuration: 0 highlightResizeDuration: 0
highlight: Item {} highlight: Item {}
section { section {
property: "isGroup" property: "isGroup"
criteria: ViewSection.FullString criteria: ViewSection.FullString
} }
PlasmaExtras.PlaceholderMessage { PlasmaExtras.PlaceholderMessage {
anchors.centerIn: parent anchors.centerIn: parent
width: parent.width - (Kirigami.Units.gridUnit * 4) width: parent.width - (Kirigami.Units.gridUnit * 4)
@ -187,7 +187,7 @@ Item {
visible: currentOwner && currentOwner.vendor && currentOwner.name 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. // Run every time an item is visually added to the list, thus when `Show n more` button is clicked as well.
add: Transition { add: Transition {
NumberAnimation { property: "opacity"; from: 0; to: 1; duration: list.animationDuration } NumberAnimation { property: "opacity"; from: 0; to: 1; duration: list.animationDuration }
@ -196,7 +196,7 @@ Item {
displaced: Transition { displaced: Transition {
NumberAnimation { properties: "y"; duration: list.animationDuration } NumberAnimation { properties: "y"; duration: list.animationDuration }
} }
function isRowExpanded(row) { function isRowExpanded(row) {
var idx = historyModel.index(row, 0); var idx = historyModel.index(row, 0);
return historyModel.data(idx, NotificationManager.Notifications.IsGroupExpandedRole); return historyModel.data(idx, NotificationManager.Notifications.IsGroupExpandedRole);
@ -219,90 +219,89 @@ Item {
} }
} }
} }
// Instantly re-align items after group expansion. // Instantly re-align items after group expansion.
forceLayout(); forceLayout();
} }
delegate: Loader { delegate: Loader {
id: delegateLoader id: delegateLoader
anchors { anchors {
left: parent ? parent.left : undefined left: parent ? parent.left : undefined
leftMargin: Kirigami.Units.gridUnit leftMargin: Kirigami.Units.gridUnit
right: parent ? parent.right : undefined right: parent ? parent.right : undefined
rightMargin: Kirigami.Units.gridUnit rightMargin: Kirigami.Units.gridUnit
} }
height: model.isGroup ? groupDelegate.height : notificationDelegate.height height: model.isGroup ? groupDelegate.height : notificationDelegate.height
sourceComponent: model.isGroup ? groupDelegate : notificationDelegate sourceComponent: model.isGroup ? groupDelegate : notificationDelegate
asynchronous: true asynchronous: true
required property var model required property var model
required property int index required property int index
// We have to do this here in order to control the animation before the item is completely removed // We have to do this here in order to control the animation before the item is completely removed
ListView.onRemove: SequentialAnimation { ListView.onRemove: SequentialAnimation {
PropertyAction { target: delegateLoader; property: "ListView.delayRemove"; value: true } PropertyAction { target: delegateLoader; property: "ListView.delayRemove"; value: true }
NumberAnimation { target: delegateLoader; property: "opacity"; to: 0.0; duration: list.animationDuration } NumberAnimation { target: delegateLoader; property: "opacity"; to: 0.0; duration: list.animationDuration }
PropertyAction { target: delegateLoader; property: "ListView.delayRemove"; value: false } PropertyAction { target: delegateLoader; property: "ListView.delayRemove"; value: false }
} }
Component { Component {
id: groupDelegate id: groupDelegate
NotificationGroupHeader { NotificationGroupHeader {
applicationName: model.applicationName applicationName: model.applicationName
applicationIconSource: model.applicationIconName applicationIconSource: model.applicationIconName
originName: model.originName || "" originName: model.originName || ""
timeSource: timeDataSource
} }
} }
Component { Component {
id: notificationDelegate id: notificationDelegate
Column { Column {
spacing: Kirigami.Units.smallSpacing spacing: Kirigami.Units.smallSpacing
height: notificationItem.height + showMoreLoader.height height: notificationItem.height + showMoreLoader.height
NotificationItem { NotificationItem {
id: notificationItem id: notificationItem
width: parent.width width: parent.width
height: implicitHeight height: implicitHeight
model: delegateLoader.model model: delegateLoader.model
modelIndex: delegateLoader.index modelIndex: delegateLoader.index
notificationsModel: root.historyModel notificationsModel: root.historyModel
notificationsModelType: root.historyModelType notificationsModelType: root.historyModelType
timeSource: timeDataSource timeSource: timeDataSource
requestToInvoke: root.actionsRequireUnlock requestToInvoke: root.actionsRequireUnlock
onRunActionRequested: { onRunActionRequested: {
list.pendingNotificationWithAction = notificationItem; list.pendingNotificationWithAction = notificationItem;
root.unlockRequested(); 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. // 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 { Loader {
id: showMoreLoader id: showMoreLoader
height: visible ? implicitHeight : 0 height: visible ? implicitHeight : 0
opacity: 0.0 opacity: 0.0
visible: active visible: active
asynchronous: true asynchronous: true
active: { active: {
// if we have the WatchedNotificationsModel, we don't have notification grouping support // if we have the WatchedNotificationsModel, we don't have notification grouping support
if (typeof model.groupChildrenCount === 'undefined') if (typeof model.groupChildrenCount === 'undefined')
return false; return false;
return (model.groupChildrenCount > model.expandedGroupChildrenCount || model.isGroupExpanded) return (model.groupChildrenCount > model.expandedGroupChildrenCount || model.isGroupExpanded)
&& delegateLoader.ListView.nextSection != delegateLoader.ListView.section; && delegateLoader.ListView.nextSection != delegateLoader.ListView.section;
} }
// state + transition: animates the item when it becomes visible. Fade off is handled by above ListView.onRemove. // state + transition: animates the item when it becomes visible. Fade off is handled by above ListView.onRemove.
states: State { states: State {
name: "VISIBLE" name: "VISIBLE"
@ -316,7 +315,7 @@ Item {
NumberAnimation { properties: "opacity"; duration: list.animationDuration } NumberAnimation { properties: "opacity"; duration: list.animationDuration }
} }
} }
sourceComponent: PlasmaComponents3.ToolButton { sourceComponent: PlasmaComponents3.ToolButton {
icon.name: model.isGroupExpanded ? "arrow-up" : "arrow-down" icon.name: model.isGroupExpanded ? "arrow-up" : "arrow-down"
text: model.isGroupExpanded ? i18n("Show Fewer") text: model.isGroupExpanded ? i18n("Show Fewer")
@ -331,31 +330,31 @@ Item {
} }
} }
} }
Item { Item {
id: toolButtons id: toolButtons
height: visible ? spacer.height + toolLayout.height + toolLayout.anchors.topMargin + toolLayout.anchors.bottomMargin : 0 height: visible ? spacer.height + toolLayout.height + toolLayout.anchors.topMargin + toolLayout.anchors.bottomMargin : 0
// do not show on lockscreen // do not show on lockscreen
visible: !root.actionsRequireUnlock visible: !root.actionsRequireUnlock
anchors { anchors {
top: list.bottom top: list.bottom
left: parent.left left: parent.left
right: parent.right right: parent.right
} }
Rectangle { Rectangle {
id: spacer id: spacer
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
visible: list.listOverflowing visible: list.listOverflowing
height: 1 height: 1
opacity: 0.25 opacity: 0.25
color: Kirigami.Theme.textColor color: Kirigami.Theme.textColor
} }
RowLayout { RowLayout {
id: toolLayout id: toolLayout
@ -368,17 +367,17 @@ Item {
topMargin: list.spacing topMargin: list.spacing
bottomMargin: list.spacing bottomMargin: list.spacing
} }
PlasmaComponents3.ToolButton { PlasmaComponents3.ToolButton {
id: clearButton id: clearButton
Layout.alignment: Qt.AlignCenter Layout.alignment: Qt.AlignCenter
visible: hasNotifications visible: hasNotifications
font.bold: true font.bold: true
font.pointSize: Kirigami.Theme.smallFont.pointSize font.pointSize: Kirigami.Theme.smallFont.pointSize
icon.name: "edit-clear-history" icon.name: "edit-clear-history"
text: i18n("Clear All Notifications") text: i18n("Clear All Notifications")
onClicked: clearHistory() onClicked: clearHistory()