shift-shell/components/mobileshell/qml/popups/notifications/NotificationPopupManager.qml
Marco Allegretti acecbcc86a Fix notification popup position in convergence mode
The input region Y was computed from the bottom of the screen
instead of from the top, causing the touch region to be offset
from the actual popup. Use openOffset directly since the popup
anchors to the top. Unify the popup margin into a single
property so the delegate x and the input-region regionX stay
consistent.
2026-04-18 20:24:48 +02:00

282 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
// Margin between popup and screen edge in convergence mode; used in both
// the delegate x position and the input-region calculation so they stay in sync.
readonly property real convergencePopupMargin: Kirigami.Units.gridUnit * 2
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 - notificationPopupManager.convergencePopupMargin;
let regionY = openOffset;
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 - notificationPopupManager.convergencePopupMargin)
: (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);
}
}
}
}
}
}