shift-shell/components/mobileshell/qml/popups/notifications/NotificationPopupManager.qml
Marco Allegretti 86283b526d Move notification popup to bottom-right in convergence
The mobile popup system always anchored to top-center which
looked out of place on a desktop screen. In convergence mode,
position popups at the bottom-right corner and slide them in
from below. Swipe-to-dismiss flips to match (drag down to
dismiss instead of up). Stacked card previews and the popup
drawer tap area are hidden since notifications are already
accessible in the action drawer.
2026-04-17 07:10:23 +02:00

279 lines
12 KiB
QML

/*
* SPDX-FileCopyrightText: 2024 Micah Stanley <stanleymicah@proton.me>
*
* SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick 2.15
import QtQuick.Layouts
import QtQuick.Window
import org.kde.kirigami as Kirigami
import org.kde.plasma.private.mobileshell as MobileShell
import org.kde.plasma.private.mobileshell.state as MobileShellState
import org.kde.plasma.private.mobileshell.shellsettingsplugin as ShellSettings
import org.kde.layershell 1.0 as LayerShell
import org.kde.notificationmanager as NotificationManager
import org.kde.plasma.clock
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)
readonly property bool isConvergence: ShellSettings.Settings.convergenceModeEnabled
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 Clock clockSource
property bool inhibited
Kirigami.Theme.colorSet: Kirigami.Theme.Complementary
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)
Binding {
target: MobileShellState.ShellDBusClient
property: "isNotificationPopupDrawerOpen"
value: popupDrawerOpened
}
// hide on timeout to give time to finish animations
Timer {
id: hideTimeout
interval: Kirigami.Units.veryLongDuration * 1.5
repeat: false
onTriggered: if (notifications.count == 0) notificationPopupManager.visible = false;
}
// 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 latest 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")
}
if (isConvergence) {
let regionX = notificationPopupManager.width - notificationPopupManager.popupWidth - Kirigami.Units.gridUnit * 4;
let regionY = Screen.height - openOffset - popupHeight - Kirigami.Units.gridUnit;
ShellUtil.setInputRegion(notificationPopupManager, Qt.rect(regionX, regionY, notificationPopupManager.popupWidth + Kirigami.Units.gridUnit * 2, popupHeight + Kirigami.Units.gridUnit * 2));
} else {
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)) ? 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);
hideTimeout.restart();
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
x: notificationPopupManager.isConvergence
? (parent.width - width - Kirigami.Units.gridUnit * 2)
: (parent.width - width) / 2
z: notifications.count - index
isConvergence: notificationPopupManager.isConvergence
popupWidth: notificationPopupManager.popupWidth
openOffset: notificationPopupManager.openOffset
keyboardInteractivity: notificationPopupManager.keyboardInteractivity
popupNotifications: notifications
popupIndex: index
popupDrawerOpened: notificationPopupManager.popupDrawerOpened
popupModel: model
notificationsModel: popupNotificationsModel
notificationsModelType: notificationModelType
timeDataSource: clockSource
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 {
if (notificationModelType === NotificationsModelType.WatchedNotificationsModel) {
popupNotificationsModel.expire(model.notificationId);
} else if (notificationModelType === NotificationsModelType.NotificationsModel) {
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);
}
}
}
}
}
}