diff --git a/CMakeLists.txt b/CMakeLists.txt index 0fb0de35..4875ed14 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -54,6 +54,7 @@ find_package(Qt6 ${QT_MIN_VERSION} CONFIG REQUIRED Quick Gui Sensors + WaylandClient ) find_package(KF6 ${KF6_MIN_VERSION} REQUIRED COMPONENTS diff --git a/components/mobileshell/CMakeLists.txt b/components/mobileshell/CMakeLists.txt index 83841061..7bbdfbef 100644 --- a/components/mobileshell/CMakeLists.txt +++ b/components/mobileshell/CMakeLists.txt @@ -23,6 +23,7 @@ set_source_files_properties(qml/dataproviders/AudioInfo.qml PROPERTIES QT_QML_SI set_source_files_properties(qml/dataproviders/BatteryInfo.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) set_source_files_properties(qml/dataproviders/BluetoothInfo.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) set_source_files_properties(qml/dataproviders/SignalStrengthInfo.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) +set_source_files_properties(qml/notificationpopup/NotificationPopupProviderLoader.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) set_source_files_properties(qml/volumeosd/VolumeOSDProviderLoader.qml PROPERTIES QT_QML_SINGLETON_TYPE TRUE) # Include qml and js files within ./qml/ @@ -41,6 +42,7 @@ target_link_libraries(mobileshellplugin Qt::Qml Qt::Gui Qt::Quick + Qt::WaylandClientPrivate KF6::KIOGui Plasma::Plasma KF6::I18n diff --git a/components/mobileshell/qml/notificationpopup/NotificationPopup.qml b/components/mobileshell/qml/notificationpopup/NotificationPopup.qml new file mode 100644 index 00000000..83aecf7a --- /dev/null +++ b/components/mobileshell/qml/notificationpopup/NotificationPopup.qml @@ -0,0 +1,535 @@ +/* + * SPDX-FileCopyrightText: 2024 Micah Stanley + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Layouts +import QtQuick.Window + +import org.kde.kirigami 2.20 as Kirigami +import org.kde.plasma.components 3.0 as PlasmaComponents +import org.kde.plasma.private.mobileshell as MobileShell +import org.kde.plasma.private.mobileshell.state as MobileShellState + +import org.kde.layershell 1.0 as LayerShell + +import org.kde.notificationmanager as NotificationManager +import org.kde.plasma.plasma5support 2.0 as P5Support + +Item { + id: notificationPopup + + readonly property int popupHeight: notificationItem.implicitHeight + readonly property bool isClosing: notificationItem.state == "closeWithMove" || notificationItem.state == "closeWithScale" + readonly property real closedOffset: -(popupHeight + Kirigami.Units.smallSpacing) + // 'popupWidth' and 'openOffset' is set by the 'notificationPopupManager' + property int popupWidth + property real openOffset + + // calculate the position needed to at when the expanded drawer is active + readonly property real fullOpenOffset: popupDrawerOpened ? aboveNotificationFullOffset + aboveNotificationHeight + Kirigami.Units.gridUnit : 0 + property real aboveNotificationFullOffset: 0 + property int aboveNotificationHeight: 0 + + // the drag offset on the current popup notification - used to position notification when stacked underneath + property real currentDragOffset: { + let current = popupNotifications.currentPopupIndex == notificationPopup.popupIndex; + return current || popupDrawerOpened ? 0 : Math.max(popupNotifications.currentDragOffset, 0) + } + + // due to it not looking great to have a notification sliding up while another one is sliding down + // we use a timer so that the current notification can know to use "closeWithScale" instead + property Timer queueTimer: Timer { + interval: Kirigami.Units.veryLongDuration + running: true + onTriggered: { + updateNotificationPopups(); + visible = true; + } + } + + // The timer for when the notification will dismiss + Timer { + id: hideTimer + interval: notificationPopup.effectiveTimeout + running: { + if (interval <= 0) { + return false; + } + if (notificationPopup.preventDismissTimeout) { + return false; + } + if (notificationPopup.inPopupDrawer) { + return false; + } + if (notificationPopup.popupDrawerOpened) { + return false; + } + return true; + } + onTriggered: notificationPopup.closePopup() + } + + // the value of how much time is left, normalized from 1 to 0 + property real remainingTimeProgress: 1 + NumberAnimation on remainingTimeProgress { + from: 1 + to: 0 + duration: hideTimer.interval + running: hideTimer.running + } + + // set the height and width of the notification container with a extra space for starting a drag + width: popupWidth + Kirigami.Units.gridUnit + height: popupHeight + Kirigami.Units.gridUnit + + visible: false + + Kirigami.Theme.colorSet: Kirigami.Theme.View + Kirigami.Theme.inherit: false + + signal expired() + signal dismissClicked() + signal updateTouchArea() + signal setInputTransparent() + signal openPopupDrawer() + signal setKeyboardFocus() + signal removeKeyboardFocus() + + // animate the notifications entering and exiting the expanded drawer + property real fullOffsetAn: fullOpenOffset + Behavior on fullOffsetAn { + NumberAnimation { + duration: Kirigami.Units.veryLongDuration * 1.5 + easing.type: Easing.OutExpo + } + } + + // when a notification is grouped behind the current Notification + // we need the y origin to be a the bottom + // however we need it at the center when "closeWithScale" is used + // animate this value so that the popup in some situations will not jump around + property real scaleOriginY: inPopupDrawer && !popupDrawerOpened ? popupNotifications.currentPopupHeight : Math.round(popupHeight / 2) + Behavior on scaleOriginY { + NumberAnimation { + duration: Kirigami.Units.veryLongDuration + easing.type: Easing.OutExpo + } + } + + // the vertical drag offset for the notification popup + // we drag is released, animate back to 0 + property real dragOffset: 0 + NumberAnimation on dragOffset { + id: dragOffsetAn + running: false + to: 0 + duration: Kirigami.Units.veryLongDuration * 1.5 + easing.type: Easing.OutExpo + } + + // if the popup height ever changes, update the notification below wiht new height + // also update the allowed touch area for the main window + onPopupHeightChanged: { + if (popupIndex + 1 < popupCount) { + popupNotifications.objectAt(popupIndex + 1).aboveNotificationHeight = popupHeight; + } + if (popupNotifications.currentPopupIndex == notificationPopup.popupIndex && notificationItem.state == "open") { + notificationPopup.updateTouchArea(); + } + } + + // if the offset position need in the expanded drawer changes, update the notification below wiht new offset + onFullOpenOffsetChanged: { + if (popupIndex + 1 < popupCount) { + popupNotifications.objectAt(popupIndex + 1).aboveNotificationFullOffset = fullOpenOffset; + } + } + // if the notification is being draged and is the current one + // update 'currentDragOffset' so all notifications can easily access this value + onDragOffsetChanged: { + if (popupNotifications.currentPopupIndex == notificationPopup.popupIndex) { + popupNotifications.currentDragOffset = dragOffset; + } + } + // if a new notification is added, update the above notification values need for the expanded drawer + onPopupCountChanged: { + if (popupIndex + 1 < popupCount) { + popupNotifications.objectAt(popupIndex + 1).aboveNotificationHeight = popupHeight; + popupNotifications.objectAt(popupIndex + 1).aboveNotificationFullOffset = fullOpenOffset; + } + } + // update the current popup index value if the index ever changes. + onPopupIndexChanged: { + if (!isClosing && !inPopupDrawer && !waiting) { + popupNotifications.currentPopupIndex = popupIndex; + } + } + // if the action drawer opens, it is best to dismiss all popup notifications + onIsActionDrawerOpenChanged: checkActionDrawerOpened() + + property bool isActionDrawerOpen: MobileShellState.ShellDBusClient.isActionDrawerOpen + + property bool waiting: true + property bool popupDrawerOpened: false + property bool inPopupDrawer: false + + property var keyboardInteractivity + property Instantiator popupNotifications + property int popupCount: popupNotifications.count + property int popupIndex + + property var popupModel + property var notificationsModel + property int notificationsModelType + property var timeDataSource + + property bool preventDismissTimeout: true + property int timeout + property int dismissTimeout + + property int defaultTimeout: 5000 + readonly property int effectiveTimeout: { + if (timeout === -1) { + return defaultTimeout; + } + if (dismissTimeout) { + return dismissTimeout; + } + return model.timeout; + } + + Component.onCompleted: checkActionDrawerOpened() + + // check if the action drawer is opened, and if so, close the popup with scale + function checkActionDrawerOpened() { + if (isActionDrawerOpen) { + if (popupNotifications.currentPopupIndex == notificationPopup.popupIndex) { + notificationPopup.setInputTransparent(); + } + notificationPopup.expired(); + keyboardInteractivity = LayerShell.Window.KeyboardInteractivityNone; + notificationItem.state = "closeWithScale"; + } + } + + // show the top most notification in the list and move the rest to the popup drawer + function updateNotificationPopups() { + if (popupCount != 1) { + for (var i = 0; i < popupCount - 1; i++) { + popupNotifications.objectAt(i + 1).moveToPopupDrawer(); + } + } + popupNotifications.objectAt(0).showNotificationPopup(); + visible = true; + } + + function showNotificationPopup() { + if (isClosing) { + closePopup(); + return; + } + if (notificationItem.state != "open") { + preventDismissTimeout = true; + } + waiting = false; + inPopupDrawer = false; + popupNotifications.currentPopupIndex = popupIndex; + visible = true; + openPopup(); + updateTouchArea(); + } + + function moveToPopupDrawer() { + if (isClosing) { + return; + } + waiting = false; + inPopupDrawer = true; + if (notificationPopup.popupDrawerOpened && notificationItem.state != "inDrawerClosed" && notificationItem.state != "open") { + notificationItem.offset = openOffset; + notificationItem.scale = 0.75; + notificationItem.opacity = 0.0; + } + notificationItem.state = "inDrawerClosed"; + notificationPopup.removeKeyboardFocus(); + visible = true; + } + + function openPopup() { + if (notificationPopup.popupDrawerOpened && notificationItem.state != "open" && notificationItem.state != "inDrawerClosed") { + notificationItem.offset = openOffset; + notificationItem.scale = 0.75; + notificationItem.opacity = 0.0; + } + notificationItem.state = "open"; + notificationPopup.removeKeyboardFocus(); + } + + // if the notification ever expires, close it and move on to the next one in the list. + property bool isExpired: model.expired + onIsExpiredChanged: closePopup() + + // this closes the popup notification with the relvent animation while updating the popup below to show, if any exist + function closePopup() { + notificationPopup.removeKeyboardFocus(); + notificationPopup.setInputTransparent(); + if (popupIndex + 1 < popupCount) { + popupNotifications.objectAt(popupIndex + 1).aboveNotificationHeight = 0; + popupNotifications.objectAt(popupIndex + 1).aboveNotificationFullOffset = 0; + } + + if (popupCount > 1) { + let nextNotificationIdx = popupIndex + (popupIndex < popupCount - 1 ? 1 : -1); + let nextNotification = popupNotifications.objectAt(nextNotificationIdx); + + if (nextNotification != null) { + nextNotification.showNotificationPopup(); + if (!isExpired) { + if (!dragOffsetAn.running && nextNotification.queueTimer.running) { + nextNotification.queueTimer.stop(); + notificationItem.state = "closeWithScale"; + } else { + notificationItem.state = "closeWithMove"; + } + return; + } + } + } + if (isExpired) { + notificationItem.close(); + return; + } + notificationItem.state = "closeWithMove"; + } + + function calculateResistance(value : double, threshold : int) : double { + if (value > threshold) { + return threshold + Math.pow(value - threshold + 1, Math.max(0.8 - (value - threshold) / ((longestLength - threshold) * 15), 0.35)); + } else { + return value; + } + } + + NotificationPopupItem { + id: notificationItem + + inPopupDrawer: notificationPopup.inPopupDrawer && !notificationPopup.popupDrawerOpened + + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + + width: notificationPopup.popupWidth + height: notificationPopup.popupHeight + + model: notificationPopup.popupModel + modelIndex: notificationPopup.popupIndex + notificationsModel: notificationPopup.notificationsModel + notificationsModelType: notificationPopup.notificationsModelType + timeSource: notificationPopup.timeDataSource + + currentPopupHeight: popupNotifications.currentPopupHeight + + remainingTimeProgress: notificationPopup.remainingTimeProgress + closeTimerRunning: hideTimer.running + + onDragStart: preventDismissTimeout = true + onDragEnd: preventDismissTimeout = (keyboardInteractivity == LayerShell.Window.KeyboardInteractivityOnDemand) + + onTakeFocus: { + notificationPopup.setKeyboardFocus(); + preventDismissTimeout = true; + } + + onDismissRequested: closePopup() + + property real offset: closedOffset + property real scale: 1.0 + property real drawerScale: 1 - Math.max(notificationPopup.popupIndex - popupNotifications.currentPopupIndex, 1) * 0.075 + Behavior on drawerScale { + NumberAnimation { + duration: Kirigami.Units.veryLongDuration + easing.type: Easing.OutExpo + } + } + property real drawerAddedOffset: Kirigami.Units.gridUnit * 0.5 * Math.max(notificationPopup.popupIndex - popupNotifications.currentPopupIndex, 1) + Behavior on drawerAddedOffset { + NumberAnimation { + duration: Kirigami.Units.veryLongDuration + easing.type: Easing.OutExpo + } + } + property real drawerOpacity: (Math.max(notificationPopup.popupIndex - popupNotifications.currentPopupIndex, 1) > 2) ? 0 : 1 + Behavior on drawerOpacity { + NumberAnimation { + duration: Kirigami.Units.veryLongDuration + easing.type: Easing.OutExpo + } + } + + opacity: 1.0 + + state: "" + + states: [ + State { + name: "open" + PropertyChanges { + target: notificationItem; offset: notificationPopup.openOffset + } + PropertyChanges { + target: notificationItem; scale: 1.0 + } + PropertyChanges { + target: notificationItem; opacity: 1.0 + } + }, + State { + name: "closeWithMove" + PropertyChanges { + target: notificationItem; offset: notificationPopup.closedOffset + } + PropertyChanges { + target: notificationItem; scale: 1.0 + } + PropertyChanges { + target: notificationItem; opacity: 1.0 + } + }, + State { + name: "closeWithScale" + PropertyChanges { + target: notificationItem; offset: notificationPopup.openOffset + } + PropertyChanges { + target: notificationItem; scale: 0.75 + } + PropertyChanges { + target: notificationItem; opacity: 0.0 + } + }, + State { + name: "inDrawerClosed" + PropertyChanges { + target: notificationItem; offset: notificationPopup.openOffset + (notificationPopup.popupDrawerOpened ? 0 : drawerAddedOffset) + } + PropertyChanges { + target: notificationItem; scale: notificationPopup.popupDrawerOpened ? 1 : drawerScale + } + PropertyChanges { + target: notificationItem; opacity: notificationPopup.popupDrawerOpened ? 1 : drawerOpacity + } + } + ] + + transitions: Transition { + SequentialAnimation { + ParallelAnimation { + PropertyAnimation { + properties: "offset"; easing.type: Easing.OutExpo; duration: Kirigami.Units.veryLongDuration * 1.5 + } + PropertyAnimation { + properties: "scale"; easing.type: Easing.OutExpo; duration: Kirigami.Units.veryLongDuration * 1.5 + } + PropertyAnimation { + properties: "opacity"; easing.type: Easing.OutExpo; duration: Kirigami.Units.veryLongDuration * 1.5 + } + } + ScriptAction { + script: { + if (notificationItem.state == "open") { + preventDismissTimeout = false; + notificationPopup.updateTouchArea(); + } else if (notificationItem.state == "closeWithMove" || notificationItem.state == "closeWithScale") { + preventDismissTimeout = true; + if (dismissTimeout) { + notificationPopup.dismissClicked(); + } else if (!isActionDrawerOpen) { + notificationPopup.expired(); + } + } + } + } + } + } + + transform: [ + Scale { + origin.x: Math.round(notificationPopup.popupWidth / 2) + origin.y: notificationPopup.scaleOriginY + xScale: notificationItem.scale + yScale: notificationItem.scale + } + ] + } + + transform: [ + Translate { + y: notificationItem.offset + notificationPopup.fullOffsetAn + notificationPopup.dragOffset + notificationPopup.currentDragOffset + } + ] + + DragHandler { + id: dragHandler + xAxis.enabled: false + yAxis.enabled: popupNotifications.currentPopupIndex == notificationPopup.popupIndex && !notificationPopup.popupDrawerOpened + target: null + + property real lastOffset: 0 + + property real startDragOffset: 0 + property real startPosition: 0 + property bool startActive: false + + onTranslationChanged: { + if (notificationItem.state == "closeWithScale" || notificationItem.state == "closeWithMove") { + return; + } + if (startActive) { + startDragOffset = notificationPopup.dragOffset; + startPosition = translation.y; + startActive = false; + } + lastOffset = notificationPopup.dragOffset; + notificationPopup.dragOffset = calculateResistance(startDragOffset + (translation.y - startPosition), 0); + } + + onActiveChanged: { + startActive = active; + notificationPopup.preventDismissTimeout = true; + if (!active && !(notificationItem.state == "closeWithScale" || notificationItem.state == "closeWithMove")) { + dragOffsetAn.running = true; + if ((lastOffset - notificationPopup.dragOffset > 1.0 && notificationPopup.dragOffset < 0) || (-(notificationPopup.openOffset - notificationPopup.closedOffset) / 4 > notificationPopup.dragOffset)) { + // this code is called when the notifition is swiped or draged to the top. + notificationPopup.closePopup(); + return; + } else if (notificationPopup.dragOffset - lastOffset > 1.0 || Kirigami.Units.gridUnit * 3 < notificationPopup.dragOffset) { + // this code is called when the notifition is swiped or draged down. + } + notificationPopup.preventDismissTimeout = (keyboardInteractivity == LayerShell.Window.KeyboardInteractivityOnDemand); + } else { + dragOffsetAn.running = false; + } + } + } + + MouseArea { + // capture taps were the notifications are grouping together to open the popup notification drawer + id: item + anchors.left: parent.left + anchors.right: parent.right + anchors.top: notificationItem.bottom + + height: Kirigami.Units.gridUnit * 2 + + enabled: !notificationPopup.popupDrawerOpened && (notificationPopup.popupCount - popupNotifications.currentPopupIndex > 1) + + onReleased: { + notificationPopup.openPopupDrawer(); + notificationPopup.updateTouchArea(); + notificationPopup.setKeyboardFocus(); + } + } +} diff --git a/components/mobileshell/qml/notificationpopup/NotificationPopupManager.qml b/components/mobileshell/qml/notificationpopup/NotificationPopupManager.qml new file mode 100644 index 00000000..9c95e2cc --- /dev/null +++ b/components/mobileshell/qml/notificationpopup/NotificationPopupManager.qml @@ -0,0 +1,250 @@ +/* + * SPDX-FileCopyrightText: 2024 Micah Stanley + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick 2.15 +import QtQuick.Layouts +import QtQuick.Window + +import org.kde.kirigami 2.20 as Kirigami +import org.kde.plasma.private.mobileshell as MobileShell +import org.kde.plasma.private.mobileshell.state as MobileShellState + +import org.kde.layershell 1.0 as LayerShell + +import org.kde.notificationmanager as NotificationManager +import org.kde.plasma.plasma5support 2.0 as P5Support + +import QtQuick.Controls as Controls +import org.kde.plasma.components 3.0 as PlasmaComponents +import org.kde.taskmanager 0.1 as TaskManager + + +/** + * This sets up and manages the notification popups + */ +Window { + id: notificationPopupManager + + readonly property int popupWidth: Math.min(Kirigami.Units.gridUnit * 20, Screen.width - Kirigami.Units.gridUnit * 2) + readonly property real openOffset: Kirigami.Units.gridUnit + Kirigami.Units.smallSpacing * 3 + readonly property int longestLength: Math.max(Screen.width, Screen.height) + property var keyboardInteractivity: LayerShell.Window.KeyboardInteractivityNone + + LayerShell.Window.scope: "notification" + LayerShell.Window.anchors: LayerShell.Window.AnchorTop | LayerShell.Window.AnchorHorizontalCenter + LayerShell.Window.layer: LayerShell.Window.LayerOverlay + LayerShell.Window.exclusionZone: -1 + LayerShell.Window.keyboardInteractivity: keyboardInteractivity + + // This toggles whether to show all the active popup notifications at ones in a list + property bool popupDrawerOpened: false + + property var notificationModelType + property QtObject notificationSettings + property QtObject popupNotificationsModel + property QtObject tasksModel + property QtObject timeSource + property bool inhibited + + Kirigami.Theme.colorSet: Kirigami.Theme.View + Kirigami.Theme.inherit: false + + readonly property color backgroundColor: Qt.darker(Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.95), 1.05) + color: popupDrawerOpened && visible ? backgroundColor : "transparent" + Behavior on color { + ColorAnimation { + duration: Kirigami.Units.veryLongDuration * 1.5 + easing.type: Easing.OutExpo + } + } + + width: longestLength + height: longestLength + + signal timeChanged + + Component.onCompleted: ShellUtil.setInputTransparent(notificationPopupManager, true) + + // Update the window touch region to encapsulate the notification area or the whole screen depending on the 'popupDrawerOpened' state + function updateTouchArea() { + ShellUtil.setInputTransparent(notificationPopupManager, false); + if (popupDrawerOpened) { + ShellUtil.setInputRegion(notificationPopupManager, Qt.rect(0, 0, 0, 0)); + } else { + // get the height of the popup directly to ensure we get the lastest version + let popupHeight = Kirigami.Units.gridUnit * 6; + let currentPopup = notifications.objectAt(notifications.currentPopupIndex); + if (currentPopup) { + popupHeight = currentPopup.popupHeight; + } else { + console.warn("popupNotification: could not retrieve current popup height - falling back to a default value") + } + + ShellUtil.setInputRegion(notificationPopupManager, Qt.rect((notificationPopupManager.width - notificationPopupManager.popupWidth - Kirigami.Units.gridUnit) / 2, openOffset - Kirigami.Units.gridUnit / 2, notificationPopupManager.popupWidth + Kirigami.Units.gridUnit, popupHeight + Kirigami.Units.gridUnit * ((notifications.count - notifications.currentPopupIndex > 1) ? 4 : 1))); + } + } + + // parent the popup notifications inside a Flickable so that they can be scrollable when the drawer state is active + Flickable { + id: flickable + width: notificationPopupManager.width + height: Screen.height + contentHeight: notifications.fullHeight + notificationPopupManager.openOffset + boundsBehavior: Flickable.DragAndOvershootBounds + bottomMargin: Kirigami.Units.gridUnit * 6 + + interactive: notificationPopupManager.popupDrawerOpened + + onDragEnded: flickable.checkDismiss(); + onFlickEnded: flickable.checkDismiss(); + onDragStarted: { + notifications.recalculateHeight(); + atBeginning = flickable.atYBeginning; + atEnd = flickable.atYEnd; + } + onFlickStarted: { + notifications.recalculateHeight(); + atBeginning = flickable.atYBeginning; + atEnd = flickable.atYEnd; + } + + property bool atBeginning: false + property bool atEnd: false + + function checkDismiss() { + let dismissFromTop = atBeginning && flickable.verticalOvershoot < -Kirigami.Units.gridUnit; + let dismissFromBottom = atEnd && flickable.verticalOvershoot > Kirigami.Units.gridUnit; + if (dismissFromTop || dismissFromBottom) { + flickable.dismiss(); + } + } + + function dismiss() { + notificationPopupManager.popupDrawerOpened = false; + notificationPopupManager.updateTouchArea(); + resetContentY.running = true; + } + + NumberAnimation on contentY { + id: resetContentY + running: false + to: 0 + duration: Kirigami.Units.veryLongDuration * 1.5 + easing.type: Easing.OutExpo + } + + MouseArea { + // capture taps behind the notifications to close the drawer + id: item + anchors.left: parent.left + anchors.right: parent.right + width: notificationPopupManager.width + height: Math.max(notifications.fullHeight, Screen.height) + + onReleased: flickable.dismiss(); + + Instantiator { + id: notifications + model: popupNotificationsModel + + // get the height, drag offset, and idx of the current popup notifition and make it easily accessible by all popup notifications + property int currentPopupHeight: (count > 0 && currentPopupIndex < count) ? objectAt(currentPopupIndex).popupHeight : 0; + property int currentDragOffset: 0 + property int currentPopupIndex: 0 + + // calculate the full height of all the notifications combine for scrolling purposes + property int fullHeight: 0 + onCountChanged: { + if (count == 0) { + ShellUtil.setInputTransparent(notificationPopupManager, true); + notificationPopupManager.visible = false; + notificationPopupManager.popupDrawerOpened = false; + fullHeight = 0; + return; + } + notificationPopupManager.visible = true; + notifications.recalculateHeight(); + } + + function recalculateHeight() { + let findHeight = 0 + for (var i = 0; i < count; i++) { + findHeight += notifications.objectAt(i).popupHeight + Kirigami.Units.gridUnit; + } + fullHeight = findHeight; + } + + delegate: NotificationPopup { + id: popup + + anchors.horizontalCenter: parent.horizontalCenter + z: notifications.count - index + + popupWidth: notificationPopupManager.popupWidth + openOffset: notificationPopupManager.openOffset + + keyboardInteractivity: notificationPopupManager.keyboardInteractivity + popupNotifications: notifications + popupIndex: index + + popupDrawerOpened: notificationPopupManager.popupDrawerOpened + + popupModel: model + notificationsModel: popupNotificationsModel + notificationsModelType: notificationModelType + timeDataSource: timeSource + + timeout: model.timeout + + onUpdateTouchArea: notificationPopupManager.updateTouchArea() + + onSetInputTransparent: ShellUtil.setInputTransparent(notificationPopupManager, true) + + onOpenPopupDrawer: notificationPopupManager.popupDrawerOpened = true + + onSetKeyboardFocus: notificationPopupManager.keyboardInteractivity = LayerShell.Window.KeyboardInteractivityOnDemand + + onRemoveKeyboardFocus: notificationPopupManager.keyboardInteractivity = LayerShell.Window.KeyboardInteractivityNone + + defaultTimeout: notificationSettings.popupTimeout + (model.urls && model.urls.length > 0 ? 5000 : 0) + + dismissTimeout: !notificationSettings.permanentJobPopups + && model.type === NotificationManager.Notifications.JobType + && model.jobState !== NotificationManager.Notifications.JobStateStopped + ? defaultTimeout : 0 + + onDismissClicked: model.dismissed = true + + onExpired: { + if (model.resident) { + // When resident, only mark it as expired so the popup disappears + // but don't actually invalidate the notification + model.expired = true; + } else { + popupNotificationsModel.expire(popupNotificationsModel.index(index, 0)); + } + } + + Component.onCompleted: { + if (model.type === NotificationManager.Notifications.NotificationType && model.desktopEntry) { + // Register apps that were seen spawning a popup so they can be configured later + // Apps with notifyrc can already be configured anyway + if (!model.notifyRcName) { + notificationSettings.registerKnownApplication(model.desktopEntry); + notificationSettings.save(); + } + } + + // Tell the model that we're handling the timeout now + popupNotificationsModel.stopTimeout(popupNotificationsModel.index(index, 0)); + + item.children.push(this); + } + } + } + } + } +} diff --git a/components/mobileshell/qml/notificationpopup/NotificationPopupProvider.qml b/components/mobileshell/qml/notificationpopup/NotificationPopupProvider.qml new file mode 100644 index 00000000..60f8eed2 --- /dev/null +++ b/components/mobileshell/qml/notificationpopup/NotificationPopupProvider.qml @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: 2024 Micah Stanley + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Layouts + +import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings +import org.kde.plasma.private.mobileshell.state as MobileShellState +import org.kde.plasma.private.mobileshell as MobileShell + +import org.kde.notificationmanager as NotificationManager +import org.kde.plasma.plasma5support 2.0 as P5Support + +import org.kde.taskmanager 0.1 as TaskManager + +/** + * This sets up and manages the notification popup settings + */ +QtObject { + id: notificationProvider + + property var notificationModelType: MobileShell.NotificationsModelType.NotificationsModel + + property QtObject notificationSettings: NotificationManager.Settings { + onNotificationsInhibitedUntilChanged: notificationProvider.checkInhibition() + } + + property QtObject popupNotificationsModel: NotificationManager.Notifications { + showExpired: false + showDismissed: false + blacklistedDesktopEntries: notificationSettings.popupBlacklistedApplications + blacklistedNotifyRcNames: notificationSettings.popupBlacklistedServices + whitelistedDesktopEntries: [] + whitelistedNotifyRcNames: [] + showJobs: notificationSettings.jobsInNotifications + sortMode: NotificationManager.Notifications.SortByTypeAndUrgency + sortOrder: Qt.DescendingOrder + groupMode: NotificationManager.Notifications.GroupDisabled + urgencies: { + var urgencies = 0; + + // Critical always except in do not disturb mode when disabled in settings + if (!notificationProvider.inhibited || notificationSettings.criticalPopupsInDoNotDisturbMode) { + urgencies |= NotificationManager.Notifications.CriticalUrgency; + } + + // Normal only when not in do not disturb mode + if (!notificationProvider.inhibited) { + urgencies |= NotificationManager.Notifications.NormalUrgency; + } + + // Low only when enabled in settings and not in do not disturb mode + if (!notificationProvider.inhibited && notificationSettings.lowPriorityPopups) { + urgencies |=NotificationManager.Notifications.LowUrgency; + } + + return urgencies; + } + } + + property bool inhibited: false + + onInhibitedChanged: { + var pa = pulseAudio.item; + if (!pa) { + return; + } + + var stream = pa.notificationStream; + if (!stream) { + return; + } + + if (inhibited) { + // Only remember that we muted if previously not muted. + if (!stream.muted) { + notificationSettings.notificationSoundsInhibited = true; + stream.mute(); + } + } else { + // Only unmute if we previously muted it. + if (notificationSettings.notificationSoundsInhibited) { + stream.unmute(); + } + notificationSettings.notificationSoundsInhibited = false; + } + notificationSettings.save(); + } + + function checkInhibition() { + notificationProvider.inhibited = Qt.binding(function() { + var inhibited = false; + + if (!NotificationManager.Server.valid) { + return false; + } + + var inhibitedUntil = notificationSettings.notificationsInhibitedUntil; + if (!isNaN(inhibitedUntil.getTime())) { + inhibited |= (Date.now() < inhibitedUntil.getTime()); + } + + if (notificationSettings.notificationsInhibitedByApplication) { + inhibited |= true; + } + + if (notificationSettings.inhibitNotificationsWhenScreensMirrored) { + inhibited |= notificationSettings.screensMirrored; + } + + return inhibited; + }); + } + + property QtObject tasksModel: TaskManager.TasksModel { + groupMode: TaskManager.TasksModel.GroupApplications + groupInline: false + } + + + property QtObject timeSource: P5Support.DataSource { + engine: "time" + connectedSources: ["Local"] + interval: 60000 // 1 min + intervalAlignment: P5Support.Types.AlignToMinute + onDataChanged: { + checkInhibition(); + npm.timeChanged(); + } + } + + property var npm: NotificationPopupManager { + notificationModelType: notificationProvider.notificationModelType + notificationSettings: notificationProvider.notificationSettings + popupNotificationsModel: notificationProvider.popupNotificationsModel + timeSource: notificationProvider.timeSource + inhibited: notificationProvider.inhibited + tasksModel: notificationProvider.tasksModel + } +} diff --git a/components/mobileshell/qml/notificationpopup/NotificationPopupProviderLoader.qml b/components/mobileshell/qml/notificationpopup/NotificationPopupProviderLoader.qml new file mode 100644 index 00000000..ab95a0c6 --- /dev/null +++ b/components/mobileshell/qml/notificationpopup/NotificationPopupProviderLoader.qml @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2024 Micah Stanley + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import org.kde.plasma.private.mobileshell as MobileShell + +pragma Singleton + +/** + * This wraps the NotificationPopupProvider component so that we can avoid loading side + * effects from imports (since this is a singleton and initialized immediately on import). + */ +Loader { + id: root + sourceComponent: Component { + MobileShell.NotificationPopupProvider {} + } + + function load() { + root.active = true; + } +} + diff --git a/components/mobileshell/qml/widgets/notifications/BaseNotificationItem.qml b/components/mobileshell/qml/widgets/notifications/BaseNotificationItem.qml index 052f97da..9deaee10 100644 --- a/components/mobileshell/qml/widgets/notifications/BaseNotificationItem.qml +++ b/components/mobileshell/qml/widgets/notifications/BaseNotificationItem.qml @@ -19,9 +19,9 @@ import org.kde.coreaddons 1.0 as KCoreAddons Item { id: notificationItem - required property var notificationsModel + property var notificationsModel - required property int notificationsModelType + property int notificationsModelType /** * Whether the notification is allowed to invoke any action, or if it should instead diff --git a/components/mobileshell/qml/widgets/notifications/NotificationBodyLabel.qml b/components/mobileshell/qml/widgets/notifications/NotificationBodyLabel.qml index 819275e8..c1cd7c1f 100644 --- a/components/mobileshell/qml/widgets/notifications/NotificationBodyLabel.qml +++ b/components/mobileshell/qml/widgets/notifications/NotificationBodyLabel.qml @@ -21,8 +21,8 @@ PlasmaComponents.Label { maximumLineCount: 3 elide: Text.ElideRight - wrapMode: Text.Wrap - textFormat: TextEdit.RichText + wrapMode: Text.WordWrap + textFormat: TextEdit.PlainText } diff --git a/components/mobileshell/qml/widgets/notifications/NotificationCard.qml b/components/mobileshell/qml/widgets/notifications/NotificationCard.qml index dc82f8d3..beee86bd 100644 --- a/components/mobileshell/qml/widgets/notifications/NotificationCard.qml +++ b/components/mobileshell/qml/widgets/notifications/NotificationCard.qml @@ -3,6 +3,7 @@ import QtQuick import QtQuick.Effects +import QtQuick.Controls import Qt5Compat.GraphicalEffects import org.kde.kirigami 2.12 as Kirigami @@ -14,6 +15,16 @@ Item { default property Item contentItem + property bool popupNotification: false + + property bool inPopupDrawer: false + + property int currentPopupHeight: 0 + + property real remainingTimeProgress: 1 + + property bool closeTimerRunning: false + property bool tapEnabled: false property bool swipeGestureEnabled: false @@ -23,6 +34,8 @@ Item { signal tapped() signal dismissRequested() signal configureClicked() // TODO implement settings button + signal dragStart() + signal dragEnd() onContentItemChanged: { contentItem.parent = contentParent; @@ -52,7 +65,7 @@ Item { blurMax: 16 shadowEnabled: true shadowVerticalOffset: 1 - shadowOpacity: 0.5 + shadowOpacity: popupNotification ? 0.85 : 0.5 shadowColor: Qt.lighter(Kirigami.Theme.backgroundColor, 0.2) } @@ -66,7 +79,7 @@ Item { color: { let darkerBackgroundColor = Qt.darker(Kirigami.Theme.backgroundColor, 1.3); - return Qt.rgba(darkerBackgroundColor.r, darkerBackgroundColor.g, darkerBackgroundColor.b, 0.3) + return Qt.rgba(darkerBackgroundColor.r, darkerBackgroundColor.g, darkerBackgroundColor.b, popupNotification ? 0.5 : 0.3) } radius: Kirigami.Units.cornerRadius } @@ -82,18 +95,65 @@ Item { anchors.rightMargin: root.dragOffset < 0 ? -root.dragOffset : 0 anchors.top: parent.top - implicitHeight: contentParent.implicitHeight + implicitHeight: inPopupDrawer ? currentPopupHeight : contentParent.implicitHeight + Behavior on implicitHeight { + NumberAnimation { + duration: Kirigami.Units.veryLongDuration + easing.type: Easing.OutExpo + } + } Rectangle { anchors.fill: parent - color: Qt.rgba(Kirigami.Theme.backgroundColor.r * 0.95, Kirigami.Theme.backgroundColor.g * 0.95, Kirigami.Theme.backgroundColor.b * 0.95, (root.tapEnabled && mouseArea.pressed) ? 0.95 : 0.85) + color: popupNotification ? Qt.lighter(Kirigami.Theme.backgroundColor, 1.5) : Qt.rgba(Kirigami.Theme.backgroundColor.r * 0.95, Kirigami.Theme.backgroundColor.g * 0.95, Kirigami.Theme.backgroundColor.b * 0.95, (root.tapEnabled && mouseArea.pressed) ? 0.95 : 0.85) + opacity: popupNotification ? 0.85 : 1 radius: Kirigami.Units.cornerRadius - layer.enabled: true + layer.enabled: popupNotification ? false : true layer.effect: MultiEffect { brightness: 0.075 } } + ProgressBar { + id: progress + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + width: root.width + height: 2 + value: remainingTimeProgress + + opacity: closeTimerRunning ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: Kirigami.Units.longDuration + easing.type: Easing.OutQuad + } + } + + background: Item + + contentItem: Item { + implicitWidth: parent.width + height: parent.height + clip: true + + Rectangle { + width: Math.min(progress.visualPosition * (parent.width + root.dragOffset), parent.width) + height: Math.max(Kirigami.Units.cornerRadius * 2, parent.height) + topLeftRadius: Kirigami.Units.cornerRadius + topRightRadius: Kirigami.Units.cornerRadius + color: Kirigami.ColorUtils.linearInterpolation(Kirigami.Theme.highlightColor, Kirigami.Theme.backgroundColor, 0.8) + } + Rectangle { + width: Math.min(progress.visualPosition * (parent.width + root.dragOffset), parent.width - Kirigami.Units.cornerRadius) + height: Math.max(Kirigami.Units.cornerRadius * 2, parent.height) + topLeftRadius: Kirigami.Units.cornerRadius + color: Kirigami.ColorUtils.linearInterpolation (Kirigami.Theme.highlightColor, Kirigami.Theme.backgroundColor, 0.8) + } + } + } + // clip layer.enabled: true @@ -124,6 +184,7 @@ Item { id: dragHandler enabled: root.swipeGestureEnabled yAxis.enabled: false + xAxis.enabled: !inPopupDrawer property real startDragOffset: 0 property real startPosition: 0 @@ -143,6 +204,7 @@ Item { startActive = active; if (!active) { // release event + root.dragEnd() let threshold = Kirigami.Units.gridUnit * 5; // drag threshold if (root.dragOffset > threshold) { dragAnim.to = root.width; @@ -152,6 +214,8 @@ Item { dragAnim.to = 0; } dragAnim.restart(); + } else { + root.dragStart() } } } diff --git a/components/mobileshell/qml/widgets/notifications/NotificationFooterActions.qml b/components/mobileshell/qml/widgets/notifications/NotificationFooterActions.qml index 524d8ebd..80b06c5f 100644 --- a/components/mobileshell/qml/widgets/notifications/NotificationFooterActions.qml +++ b/components/mobileshell/qml/widgets/notifications/NotificationFooterActions.qml @@ -15,10 +15,13 @@ Item { id: actionContainer required property BaseNotificationItem notification + property bool popupNotification: false implicitHeight: Math.max(actionFlow.implicitHeight, replyLoader.height) visible: actionRepeater.count > 0 + signal takeFocus() + Flow { id: actionFlow width: parent.width @@ -63,6 +66,8 @@ Item { flat: false text: modelData.label || "" + visible: !(notificationItem.hasReplyAction && actionContainer.popupNotification) + onClicked: { if (modelData.actionName === "inline-reply") { replyLoader.beginReply(); @@ -81,11 +86,13 @@ Item { height: active ? item.implicitHeight : 0 // When there is only one action and it is a reply action, show text field right away - active: false + active: notificationItem.hasReplyAction && actionContainer.popupNotification visible: active opacity: active ? 1 : 0 x: active ? 0 : parent.width + property bool replying: false + Behavior on x { NumberAnimation { duration: Kirigami.Units.longDuration @@ -100,7 +107,9 @@ Item { } function beginReply() { - active = true + actionContainer.takeFocus(); + active = true; + replying = true; replyLoader.item.activate(); } @@ -110,7 +119,7 @@ Item { buttonText: notificationItem.replySubmitButtonText onReplied: notificationItem.replied(text) - replying: replyLoader.active + replying: replyLoader.replying onBeginReplyRequested: replyLoader.beginReply() } } diff --git a/components/mobileshell/qml/widgets/notifications/NotificationItem.qml b/components/mobileshell/qml/widgets/notifications/NotificationItem.qml index 0eb03c7f..5a94b764 100644 --- a/components/mobileshell/qml/widgets/notifications/NotificationItem.qml +++ b/components/mobileshell/qml/widgets/notifications/NotificationItem.qml @@ -26,6 +26,10 @@ BaseNotificationItem { property bool inLockscreen: false + signal dragStart() + signal dragEnd() + signal takeFocus() + // notification heading for groups with one element NotificationGroupHeader { id: notificationHeading @@ -57,16 +61,19 @@ BaseNotificationItem { tapEnabled: notificationItem.hasDefaultAction onTapped: notificationItem.actionInvoked("default"); swipeGestureEnabled: notificationItem.closable - onDismissRequested: notificationItem.close() + onDismissRequested: notificationItem.close(); + + onDragStart: notificationItem.dragStart() + onDragEnd: notificationItem.dragEnd() ColumnLayout { id: column spacing: 0 - // notification summary row RowLayout { - Layout.fillWidth: true + Layout.alignment: Qt.AlignRight + Layout.fillWidth: summaryLabel.visible Layout.bottomMargin: Kirigami.Units.smallSpacing // notification summary @@ -107,7 +114,7 @@ BaseNotificationItem { Layout.alignment: Qt.AlignTop | Qt.AlignLeft Layout.preferredWidth: column.width - iconContainer.width - Kirigami.Units.smallSpacing - text: notificationItem.body + text: ShellUtil.toPlainText(notificationItem.body) } // notification icon @@ -165,6 +172,7 @@ BaseNotificationItem { Layout.fillWidth: true Layout.topMargin: Kirigami.Units.smallSpacing notification: notificationItem + onTakeFocus: notificationItem.takeFocus() } // thumbnails diff --git a/components/mobileshell/qml/widgets/notifications/NotificationPopupItem.qml b/components/mobileshell/qml/widgets/notifications/NotificationPopupItem.qml new file mode 100644 index 00000000..cc48027e --- /dev/null +++ b/components/mobileshell/qml/widgets/notifications/NotificationPopupItem.qml @@ -0,0 +1,234 @@ +/* + * SPDX-FileCopyrightText: 2021 Devin Lin + * SPDX-FileCopyrightText: 2018-2019 Kai Uwe Broulik + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +import QtQuick 2.15 +import QtQuick.Layouts 1.1 +import QtQuick.Window 2.2 + +import org.kde.plasma.private.mobileshell as MobileShell +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.components 3.0 as PlasmaComponents + +import org.kde.notificationmanager as NotificationManager + +import org.kde.kirigami 2.12 as Kirigami + +import org.kde.coreaddons 1.0 as KCoreAddons + +// notification properties are in BaseNotificationItem +BaseNotificationItem { + id: notificationItem + implicitHeight: mainCard.implicitHeight + + property bool inPopupDrawer: false + property int currentPopupHeight: 0 + property real remainingTimeProgress: 1 + property bool closeTimerRunning: false + + property bool inLockscreen: false + + signal dragStart() + signal dragEnd() + signal takeFocus() + signal dismissRequested() + + // notification + NotificationCard { + id: mainCard + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + popupNotification: true + inPopupDrawer: notificationItem.inPopupDrawer + currentPopupHeight: notificationItem.currentPopupHeight + remainingTimeProgress: notificationItem.remainingTimeProgress + closeTimerRunning: notificationItem.closeTimerRunning + tapEnabled: notificationItem.hasDefaultAction + onTapped: notificationItem.actionInvoked("default"); + swipeGestureEnabled: notificationItem.closable + onDismissRequested: { + model.resident = false; + notificationItem.dismissRequested(); + notificationItem.close(); + } + + onDragStart: notificationItem.dragStart() + onDragEnd: notificationItem.dragEnd() + + ColumnLayout { + id: column + spacing: 0 + + opacity: notificationItem.inPopupDrawer ? 0 : 1 + Behavior on opacity { + NumberAnimation { + duration: Kirigami.Units.veryLongDuration + easing.type: Easing.OutExpo + } + } + + // notification summary row + RowLayout { + Layout.alignment: Qt.AlignRight + Layout.fillWidth: true + Layout.bottomMargin: Kirigami.Units.smallSpacing + + Kirigami.Icon { + id: applicationIconItem + Layout.topMargin: Kirigami.Units.smallSpacing + Layout.bottomMargin: Kirigami.Units.smallSpacing + Layout.preferredWidth: Kirigami.Units.iconSizes.small + Layout.preferredHeight: Kirigami.Units.iconSizes.small + source: notificationItem.applicationIconSource + visible: valid + } + + PlasmaComponents.Label { + id: applicationNameLabel + Layout.leftMargin: Kirigami.Units.smallSpacing + Layout.fillWidth: true + + color: Kirigami.Theme.textColor + + elide: Text.ElideLeft + font.pointSize: Kirigami.Theme.defaultFont.pointSize + text: notificationItem.applicationName + (notificationItem.originName ? " ยท " + notificationItem.originName : "") + } + + // notification timestamp + NotificationTimeText { + Layout.rightMargin: Kirigami.Units.smallSpacing + Layout.topMargin: Kirigami.Units.smallSpacing + Layout.bottomMargin: Kirigami.Units.smallSpacing + notificationType: notificationItem.notificationType + jobState: notificationItem.jobState + jobDetails: notificationItem.jobDetails + + time: notificationItem.time + timeSource: notificationItem.timeSource + } + } + + // notification contents + RowLayout { + Layout.fillWidth: true + Layout.bottomMargin: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.smallSpacing + Layout.alignment: Qt.AlignTop + + ColumnLayout { + Layout.alignment: Qt.AlignTop + + // notification summary + PlasmaComponents.Label { + id: summaryLabel + Layout.fillWidth: true + textFormat: Text.PlainText + maximumLineCount: 3 + wrapMode: Text.WordWrap + elide: Text.ElideRight + font.pointSize: Kirigami.Theme.defaultFont.pointSize + text: MobileShell.NotificationsUtils.determineNotificationHeadingText(notificationItem) + visible: text !== "" + font.weight: Font.DemiBold + } + + + // notification text + NotificationBodyLabel { + id: bodyLabel + Layout.alignment: Qt.AlignTop | Qt.AlignLeft + Layout.preferredWidth: column.width - iconContainer.width - Kirigami.Units.smallSpacing + + text: ShellUtil.toPlainText(notificationItem.body) + } + + } + + // notification icon + Item { + id: iconContainer + Layout.fillHeight: true + Layout.preferredWidth: Kirigami.Units.iconSizes.medium + Layout.preferredHeight: Kirigami.Units.iconSizes.medium + Layout.topMargin: Kirigami.Units.smallSpacing + Layout.bottomMargin: Kirigami.Units.smallSpacing + + visible: iconItem.shouldBeShown + + Kirigami.Icon { + id: iconItem + // don't show two identical icons + readonly property bool shouldBeShown: valid && source != notificationItem.applicationIconSource + anchors.fill: parent + smooth: true + // don't show a generic "info" icon since this is a notification already + source: notificationItem.icon !== "dialog-information" ? notificationItem.icon : "" + visible: shouldBeShown + } + } + } + + // 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 + onTakeFocus: notificationItem.takeFocus() + + popupNotification: true + } + + // thumbnails + Loader { + id: thumbnailStripLoader + Layout.topMargin: Kirigami.Units.largeSpacing + Layout.fillWidth: true + active: notificationItem.urls.length > 0 + visible: active + asynchronous: true + sourceComponent: ThumbnailStrip { + leftPadding: -thumbnailStripLoader.Layout.leftMargin + rightPadding: -thumbnailStripLoader.Layout.rightMargin + topPadding: -notificationItem.thumbnailTopPadding + bottomPadding: -thumbnailStripLoader.Layout.bottomMargin + urls: notificationItem.urls + onOpenUrl: notificationItem.openUrl(url) + onFileActionInvoked: notificationItem.fileActionInvoked(action) + } + } + } + } +} diff --git a/components/mobileshell/shellutil.cpp b/components/mobileshell/shellutil.cpp index 740d65f5..9ebd96f3 100644 --- a/components/mobileshell/shellutil.cpp +++ b/components/mobileshell/shellutil.cpp @@ -22,12 +22,15 @@ #include #include +#include +#include + + #define FORMAT24H "HH:mm:ss" -ShellUtil::ShellUtil(QObject *parent) - : QObject{parent} - , m_localeConfig{KSharedConfig::openConfig(QStringLiteral("kdeglobals"), KConfig::SimpleConfig)} -{ + +ShellUtil::ShellUtil(QObject *parent) : QObject{parent}, m_localeConfig { + KSharedConfig::openConfig(QStringLiteral("kdeglobals"), KConfig::SimpleConfig) } { } void ShellUtil::stackItemBefore(QQuickItem *item1, QQuickItem *item2) @@ -102,3 +105,44 @@ void ShellUtil::setInputTransparent(QQuickWindow *window, bool transparent) { } } +void ShellUtil::setInputRegion(QWindow *window, const QRect ®ion) { + auto waylandWindow = dynamic_cast(window->handle()); + if (!waylandWindow) { + qWarning() << "Failed to retrieve Wayland window handle."; + return; + } + + auto waylandDisplay = dynamic_cast(waylandWindow->display()); + if (!waylandDisplay) { + qWarning() << "Failed to retrieve Wayland display."; + return; + } + + wl_compositor *compositorResource = static_cast(waylandDisplay->compositor()->object()); + if (!compositorResource) { + qWarning() << "Failed to retrieve compositor."; + return; + } + + wl_surface *surface = waylandWindow->wlSurface(); + if (!surface) { + qWarning() << "Failed to retrieve Wayland surface."; + return; + } + + if (region.isEmpty()) { + wl_surface_set_input_region(surface, nullptr); + } else { + wl_region *inputRegion = wl_compositor_create_region(compositorResource); + + wl_region_add(inputRegion, region.x(), region.y(), region.width(), region.height()); + wl_surface_set_input_region(surface, inputRegion); + wl_region_destroy(inputRegion); + } + + wl_surface_commit(surface); +} + +QString ShellUtil::toPlainText(QString htmlString) { + return QTextDocumentFragment::fromHtml(htmlString).toPlainText(); +} diff --git a/components/mobileshell/shellutil.h b/components/mobileshell/shellutil.h index 00c9766f..6b61745c 100644 --- a/components/mobileshell/shellutil.h +++ b/components/mobileshell/shellutil.h @@ -71,6 +71,18 @@ public: */ Q_INVOKABLE void setInputTransparent(QQuickWindow *window, bool transparent); + /** + * Sets a region where inputs will get registered on a window. + * Inputs outside the region will pass through to the surface below. + * Set this to empty to fill the whole window again. + */ + Q_INVOKABLE void setInputRegion(QWindow *window, const QRect ®ion); + + /** + * Converts rich text to plain text. + */ + Q_INVOKABLE QString toPlainText(QString htmlString); + Q_SIGNALS: void isSystem24HourFormatChanged(); diff --git a/containments/panel/package/contents/ui/main.qml b/containments/panel/package/contents/ui/main.qml index c9c55933..ba10b60c 100644 --- a/containments/panel/package/contents/ui/main.qml +++ b/containments/panel/package/contents/ui/main.qml @@ -95,6 +95,8 @@ ContainmentItem { // HACK: we need to initialize the DBus server somewhere, it might as well be here... // initialize the volume osd, and volume keys MobileShell.VolumeOSDProviderLoader.load(); + // initialize notification popups + MobileShell.NotificationPopupProviderLoader.load(); } MobileShell.StartupFeedbackPanelFill { diff --git a/layout-templates/org.kde.plasma.mobile.defaultStatusBar/contents/layout.js b/layout-templates/org.kde.plasma.mobile.defaultStatusBar/contents/layout.js index b6b93956..d9f14cfa 100644 --- a/layout-templates/org.kde.plasma.mobile.defaultStatusBar/contents/layout.js +++ b/layout-templates/org.kde.plasma.mobile.defaultStatusBar/contents/layout.js @@ -3,5 +3,4 @@ const panel = new Panel("org.kde.plasma.mobile.panel"); panel.location = "top"; -panel.addWidget("org.kde.plasma.notifications"); panel.height = 1.25 * gridUnit; // HACK: supposed to be gridUnit + smallSpacing, but it doesn't seem to give the correct number diff --git a/shell/contents/updates/6_3_update.js b/shell/contents/updates/6_3_update.js new file mode 100644 index 00000000..f54b4fa1 --- /dev/null +++ b/shell/contents/updates/6_3_update.js @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2024 Micah +// SPDX-License-Identifier: GPL-2.0-or-later + +/** + * Configuration updates for Plasma 6.3 + * - Mobile popup notifications were added, remove the old ones. + */ + +let allPanels = panels(); +for (var i = 0; i < allPanels.length; i++) { + if (allPanels[i].type === "org.kde.plasma.mobile.panel") { + let allWidgetIds = allPanels[i].widgetIds; + for (var w = 0; w < allWidgetIds.length; w++) { + let widget = allPanels[i].widgetById(allWidgetIds[w]); + if (widget.type === "org.kde.plasma.notifications") { + widget.remove(); + } + } + } +} diff --git a/tests/notificationtest/tests.cpp b/tests/notificationtest/tests.cpp index bb71768c..2041301b 100644 --- a/tests/notificationtest/tests.cpp +++ b/tests/notificationtest/tests.cpp @@ -67,7 +67,7 @@ void LowUrgencyNotificationTest::sendNotification(QCoreApplication &app) notification->setIconName(QStringLiteral("notification-inactive")); notification->setTitle("Low Urgency Notification"); notification->setText("This is not very important..."); - notification->setUrgency(KNotification::CriticalUrgency); + notification->setUrgency(KNotification::LowUrgency); connect(notification, &KNotification::closed, &app, QCoreApplication::quit); notification->sendEvent(); @@ -80,7 +80,7 @@ void HighUrgencyNotificationTest::sendNotification(QCoreApplication &app) notification->setIconName(QStringLiteral("notification-active")); notification->setTitle("Urgent Notification"); notification->setText("This is very urgent! AAAAAA"); - notification->setUrgency(KNotification::CriticalUrgency); + notification->setUrgency(KNotification::HighUrgency); connect(notification, &KNotification::closed, &app, QCoreApplication::quit); notification->sendEvent();