Files

582 lines
19 KiB
QML

pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Modules.Notifications
import qs.Services
QtObject {
id: manager
property var modelData
property int topMargin: 0
readonly property bool notificationConnectedMode: CompositorService.usesConnectedFrameChromeForScreen(manager.modelData)
readonly property bool closeGapNotifications: notificationConnectedMode && SettingsData.frameCloseGaps
readonly property string notifBarSide: {
const pos = SettingsData.notificationPopupPosition;
if (pos === -1)
return "top";
switch (pos) {
case SettingsData.Position.Top:
return "right";
case SettingsData.Position.Left:
return "left";
case SettingsData.Position.BottomCenter:
return "bottom";
case SettingsData.Position.Right:
return "right";
case SettingsData.Position.Bottom:
return "left";
default:
return "top";
}
}
readonly property real popupSpacing: notificationConnectedMode ? 0 : Theme.groupedListGap
readonly property int motionStagger: NotificationMetrics.animationsEnabled ? Theme.notificationStackStaggerDuration : 0
property var popupWindows: []
property var destroyingWindows: new Set()
property var pendingDestroys: []
property int destroyDelayMs: 100
property bool _chromeSyncPending: false
property bool _syncingVisibleNotifications: false
property var _enterQueue: []
property var _exitQueue: []
property real _lastEnterMs: 0
property real _lastExitMs: 0
readonly property real chromeReleaseTailStart: 0.90
property Component popupComponent
popupComponent: Component {
NotificationPopup {
onExitFinished: manager._onPopupExitFinished(this)
onExitRequested: manager._onPopupExitRequested(this)
onSurfaceMapped: manager._pumpMotion()
onPopupHeightChanged: manager._onPopupHeightChanged(this)
onPopupChromeGeometryChanged: manager._onPopupChromeGeometryChanged(this)
}
}
property Connections notificationConnections
notificationConnections: Connections {
function onVisibleNotificationsChanged() {
manager._sync(NotificationService.visibleNotifications);
}
target: NotificationService
}
property Timer sweeper
property Timer motionTimer: Timer {
running: false
repeat: false
onTriggered: manager._pumpMotion()
}
property Timer destroyTimer: Timer {
interval: destroyDelayMs
running: false
repeat: false
onTriggered: manager._processDestroyQueue()
}
function _processDestroyQueue() {
if (pendingDestroys.length === 0)
return;
const p = pendingDestroys.shift();
if (p && p.destroy) {
try {
p.destroy();
} catch (e) {}
}
if (pendingDestroys.length > 0)
destroyTimer.restart();
}
function _scheduleDestroy(p) {
if (!p)
return;
pendingDestroys.push(p);
if (!destroyTimer.running)
destroyTimer.restart();
}
sweeper: Timer {
interval: 500
running: false
repeat: true
onTriggered: {
const toRemove = [];
for (const p of popupWindows) {
if (!p) {
toRemove.push(p);
continue;
}
const neverMapped = !p._entryStarted && !p.surfaceReady && ++p.unmappedSweeps > 1;
const isZombie = p.status === Component.Null || (!p.visible && !p.exiting) || (!p.notificationData && !p._isDestroying) || (!p.hasValidData && !p._isDestroying) || neverMapped;
if (isZombie) {
toRemove.push(p);
if (p.forceExit) {
p.forceExit();
} else if (p.destroy) {
try {
p.destroy();
} catch (e) {}
}
}
}
if (toRemove.length) {
popupWindows = popupWindows.filter(p => toRemove.indexOf(p) === -1);
_forgetQueued(toRemove);
_repositionAll();
}
if (popupWindows.length === 0)
sweeper.stop();
}
}
function _hasWindowFor(w) {
return popupWindows.some(p => p && p.notificationData === w && !p._isDestroying && p.status !== Component.Null);
}
function _isValidWindow(p) {
return p && p.status !== Component.Null && !p._isDestroying && p.hasValidData;
}
function _isLayoutWindow(p) {
return _isValidWindow(p) && p._entryStarted && !p.exitStarted;
}
function _layoutWindows() {
return popupWindows.filter(_isLayoutWindow);
}
function _chromeWindows() {
return popupWindows.filter(p => _isValidWindow(p) && p.visible && p.presenting);
}
function _sync(newWrappers) {
_syncingVisibleNotifications = true;
for (const p of popupWindows.slice()) {
if (!_isValidWindow(p) || p.exiting)
continue;
if (p.notificationData && newWrappers.indexOf(p.notificationData) === -1) {
p.notificationData.removedByLimit = true;
p.notificationData.popup = false;
}
}
for (const w of newWrappers) {
if (w && !_hasWindowFor(w) && NotificationService.isFocusedScreen(manager.modelData))
_insertAtTop(w);
}
_syncingVisibleNotifications = false;
_pumpMotion();
}
function _popupHeight(p) {
return p.layoutHeight + popupSpacing;
}
function _insertAtTop(wrapper) {
if (!wrapper)
return;
const notificationId = wrapper?.notification ? wrapper.notification.id : "";
const win = popupComponent.createObject(null, {
"notificationData": wrapper,
"notificationId": notificationId,
"screen": manager.modelData
});
if (!win)
return;
if (!win.hasValidData) {
win.destroy();
return;
}
let insertIndex = 0;
for (let i = 0; i < popupWindows.length; i++) {
if (popupWindows[i]?.layoutPinned)
insertIndex = i + 1;
}
popupWindows.splice(insertIndex, 0, win);
win.setStackPosition(_stackPositionFor(win));
_enterQueue.push(win);
if (!sweeper.running)
sweeper.start();
}
function _pumpMotion() {
const now = Date.now();
let wait = Infinity;
while (_enterQueue.length > 0) {
if (!_enterQueue[0].surfaceReady)
break;
const due = _lastEnterMs + motionStagger - now;
if (due > 0) {
wait = Math.min(wait, due);
break;
}
_lastEnterMs = now;
_startEntry(_enterQueue.shift());
}
while (_exitQueue.length > 0) {
const due = _lastExitMs + motionStagger - now;
if (due > 0) {
wait = Math.min(wait, due);
break;
}
_lastExitMs = now;
_startExit(_exitQueue.shift());
}
if (wait === Infinity)
return;
motionTimer.interval = Math.max(1, Math.round(wait));
motionTimer.restart();
}
function _startEntry(win) {
if (!_isValidWindow(win) || popupWindows.indexOf(win) === -1)
return;
win.beginEntry();
_repositionAll();
}
function _startExit(win) {
if (!_isValidWindow(win) || popupWindows.indexOf(win) === -1)
return;
const collapseChrome = notificationConnectedMode && _chromeWindows().length > 1;
win.beginExit(collapseChrome);
_repositionAll();
}
function _forgetQueued(windows) {
_enterQueue = _enterQueue.filter(w => windows.indexOf(w) === -1);
_exitQueue = _exitQueue.filter(w => windows.indexOf(w) === -1);
}
function _stackPositionFor(target) {
let currentY = topMargin;
for (const p of popupWindows) {
if (p === target)
return currentY;
if (!_isLayoutWindow(p))
continue;
const gap = p.layoutPinned ? Math.max(0, p.stackTarget - currentY) : 0;
currentY += gap + _popupHeight(p);
}
return currentY;
}
function _repositionAll() {
let currentY = topMargin;
for (const win of _layoutWindows()) {
const gap = win.layoutPinned ? Math.max(0, win.stackTarget - currentY) : 0;
const position = currentY + gap;
win.setStackPosition(position);
currentY = position + _popupHeight(win);
}
_scheduleNotificationChromeSync();
}
function _scheduleNotificationChromeSync() {
if (_chromeSyncPending)
return;
_chromeSyncPending = true;
Qt.callLater(() => {
_chromeSyncPending = false;
_syncNotificationChromeState();
});
}
function _clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function _clipRectFromBarSide(rect, visibleFraction) {
const fraction = _clamp01(visibleFraction);
const w = Math.max(0, rect.right - rect.x);
const h = Math.max(0, rect.bottom - rect.y);
if (notifBarSide === "right") {
rect.x = rect.right - w * fraction;
} else if (notifBarSide === "left") {
rect.right = rect.x + w * fraction;
} else if (notifBarSide === "bottom") {
rect.y = rect.bottom - h * fraction;
} else {
rect.bottom = rect.y + h * fraction;
}
return rect;
}
function _popupChromeVisibleFraction(p) {
const swipe = p.swipeReleaseProgress();
let swipeVisible = 1;
if (swipe > 0)
swipeVisible = p.swipeDismissTowardEdge ? 1 - swipe : 1 - _chromeReleaseTailProgress(swipe);
return _clamp01(Math.min(p.presentationProgress, swipeVisible));
}
function _popupChromeRect(p, useMotionOffset) {
if (!p || !p.screen)
return null;
const x = p.getContentX();
const y = p.getContentY();
const w = p.alignedWidth || 0;
const h = p.alignedHeight || 0;
if (w <= 0 || h <= 0)
return null;
const rect = {
x: x,
y: y,
right: x + w,
bottom: y + h
};
if (p.exiting && p.chromeRelease > 0) {
const shrink = h * _clamp01(p.chromeRelease);
if (_stackAnchorsTop())
rect.bottom = Math.max(rect.y, rect.bottom - shrink);
else
rect.y = Math.min(rect.bottom, rect.y + shrink);
}
if (!useMotionOffset || p.isCenterPosition)
return rect;
return _clipRectFromBarSide(rect, _popupChromeVisibleFraction(p));
}
function _chromeReleaseTailProgress(rawProgress) {
const progress = _clamp01(rawProgress);
if (progress <= chromeReleaseTailStart)
return 0;
return _clamp01((progress - chromeReleaseTailStart) / Math.max(0.001, 1 - chromeReleaseTailStart));
}
function _stackAnchorsTop() {
const pos = SettingsData.notificationPopupPosition;
return pos === -1 || pos === SettingsData.Position.Top || pos === SettingsData.Position.Left;
}
function _frameEdgeInset(side) {
if (!manager.modelData)
return 0;
const raw = SettingsData.frameEdgeReservation(manager.modelData, side);
const dpr = CompositorService.getScreenScale(manager.modelData);
return Math.max(0, Math.round(Theme.px(raw, dpr)));
}
function _closeGapChromeAnchorEdge(anchorsTop) {
if (!closeGapNotifications || !manager.modelData)
return null;
if (anchorsTop)
return _frameEdgeInset("top") + topMargin;
return manager.modelData.height - _frameEdgeInset("bottom") - topMargin;
}
function _stackAnchoredChromeEdge(candidates) {
const anchorsTop = _stackAnchorsTop();
let edge = anchorsTop ? Infinity : -Infinity;
for (const p of candidates) {
const rect = _popupChromeRect(p, false);
if (!rect)
continue;
if (anchorsTop && rect.y < edge)
edge = rect.y;
if (!anchorsTop && rect.bottom > edge)
edge = rect.bottom;
}
if (edge === Infinity || edge === -Infinity)
return null;
return {
anchorsTop: anchorsTop,
edge: edge
};
}
function _syncNotificationChromeState() {
const screenName = manager.modelData?.name || "";
if (!screenName)
return;
const ownerId = "notification:" + screenName;
if (!notificationConnectedMode) {
ConnectedModeState.releaseSurface(screenName, "notification", ownerId);
return;
}
const active = _chromeWindows();
if (active.length === 0) {
ConnectedModeState.releaseSurface(screenName, "notification", ownerId);
return;
}
let minX = Infinity;
let minY = Infinity;
let maxXEnd = -Infinity;
let maxYEnd = -Infinity;
const useMotionOffset = active.length === 1 && active[0].popupChromeMotionActive();
for (const p of active) {
const rect = _popupChromeRect(p, useMotionOffset);
if (!rect)
continue;
if (rect.x < minX)
minX = rect.x;
if (rect.y < minY)
minY = rect.y;
if (rect.right > maxXEnd)
maxXEnd = rect.right;
if (rect.bottom > maxYEnd)
maxYEnd = rect.bottom;
}
const stackEdge = _stackAnchoredChromeEdge(active);
if (stackEdge !== null) {
if (stackEdge.anchorsTop && stackEdge.edge < minY)
minY = stackEdge.edge;
if (!stackEdge.anchorsTop && stackEdge.edge > maxYEnd)
maxYEnd = stackEdge.edge;
}
const anchorsTop = stackEdge !== null ? stackEdge.anchorsTop : _stackAnchorsTop();
const closeGapAnchorEdge = _closeGapChromeAnchorEdge(anchorsTop);
if (closeGapAnchorEdge !== null) {
if (anchorsTop)
minY = closeGapAnchorEdge;
else
maxYEnd = closeGapAnchorEdge;
}
if (minX === Infinity || minY === Infinity || maxXEnd <= minX || maxYEnd <= minY) {
ConnectedModeState.releaseSurface(screenName, "notification", ownerId);
return;
}
const bodyRect = {
x: minX,
y: minY,
width: maxXEnd - minX,
height: maxYEnd - minY
};
ConnectedModeState.claimSurface(screenName, "notification", {
kind: "notification",
screenName: screenName,
phase: "open",
visible: true,
presented: true,
barSide: notifBarSide,
bodyRect: bodyRect,
animationOffset: {
x: 0,
y: 0
},
scale: 1,
opacity: Theme.connectedSurfaceColor.a,
bodyX: minX,
bodyY: minY,
bodyW: bodyRect.width,
bodyH: bodyRect.height,
omitStartConnector: _notificationOmitStartConnector(),
omitEndConnector: _notificationOmitEndConnector()
}, ownerId);
}
function _notificationOmitStartConnector() {
return closeGapNotifications && (SettingsData.notificationPopupPosition === SettingsData.Position.Top || SettingsData.notificationPopupPosition === SettingsData.Position.Left);
}
function _notificationOmitEndConnector() {
return closeGapNotifications && (SettingsData.notificationPopupPosition === SettingsData.Position.Right || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom);
}
function _onPopupChromeGeometryChanged(p) {
if (!p || popupWindows.indexOf(p) === -1)
return;
_scheduleNotificationChromeSync();
}
function _onPopupHeightChanged(p) {
if (!p || p._isDestroying || popupWindows.indexOf(p) === -1)
return;
if (!_syncingVisibleNotifications)
_repositionAll();
}
function _onPopupExitRequested(p) {
if (!p || popupWindows.indexOf(p) === -1)
return;
if (p.notificationData?.removedByLimit) {
const now = Date.now();
_lastExitMs = now;
_lastEnterMs = now;
_startExit(p);
_pumpMotion();
return;
}
_exitQueue.push(p);
_pumpMotion();
}
function _onPopupExitFinished(p) {
if (!p)
return;
const windowId = p.toString();
if (destroyingWindows.has(windowId))
return;
destroyingWindows.add(windowId);
const i = popupWindows.indexOf(p);
if (i !== -1) {
popupWindows.splice(i, 1);
popupWindows = popupWindows.slice();
}
_forgetQueued([p]);
if (NotificationService.releaseWrapper && p.notificationData)
NotificationService.releaseWrapper(p.notificationData);
_scheduleDestroy(p);
Qt.callLater(() => destroyingWindows.delete(windowId));
_repositionAll();
_pumpMotion();
}
function cleanupAllWindows() {
sweeper.stop();
destroyTimer.stop();
motionTimer.stop();
pendingDestroys = [];
_enterQueue = [];
_exitQueue = [];
for (const p of popupWindows.slice()) {
if (p) {
try {
if (p.forceExit) {
p.forceExit();
} else if (p.destroy) {
p.destroy();
}
} catch (e) {}
}
}
popupWindows = [];
destroyingWindows.clear();
_chromeSyncPending = false;
_syncNotificationChromeState();
}
Component.onCompleted: _sync(NotificationService.visibleNotifications)
Component.onDestruction: {
for (const p of popupWindows)
p?.destroy();
const screenName = manager.modelData?.name || "";
if (screenName)
ConnectedModeState.releaseSurface(screenName, "notification", "notification:" + screenName);
}
onPopupSpacingChanged: _repositionAll()
onNotificationConnectedModeChanged: _scheduleNotificationChromeSync()
onCloseGapNotificationsChanged: _scheduleNotificationChromeSync()
onNotifBarSideChanged: _scheduleNotificationChromeSync()
onModelDataChanged: _scheduleNotificationChromeSync()
onTopMarginChanged: _repositionAll()
onPopupWindowsChanged: {
if (popupWindows.length > 0 && !sweeper.running) {
sweeper.start();
} else if (popupWindows.length === 0 && sweeper.running) {
sweeper.stop();
}
}
}