mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
8d9d7ff0ef
The notification popup's close button (X), action button clicks, and the cardClick body's else branch all set notificationData.popup = false directly. This relies on wrapperConn.onPopupChanged firing startExit(), which can be interrupted by four races: 1. enterDelay (160 ms Timer) starts notificationData.timer after the user clicks, on an already-orphan wrapper. 2. The dismiss Timer keeps running post-click and flips popup again. 3. wrapperConn.target is set imperatively in onNotificationDataChanged (line 293) and may be stale after NotificationPopupManager._sync() reorders wrappers. 4. exiting or _isDestroying stuck true gates wrapperConn.enabled. Introduce a single helper dismissPopupReliably() that stops the timer, sets popup = false, and kicks off startExit() via Qt.callLater as a belt-and-suspenders fallback. Apply to the three vulnerable handlers. This matches the existing upstream pattern used in the hover, contextMenu, and Component.onDestruction paths (which all stop the timer before mutating popup state). Closes #2760
1446 lines
59 KiB
QML
1446 lines
59 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell
|
|
import Quickshell.Wayland
|
|
import Quickshell.Services.Notifications
|
|
import qs.Common
|
|
import qs.Modules.Notifications
|
|
import qs.Services
|
|
import qs.Widgets
|
|
|
|
PanelWindow {
|
|
id: win
|
|
|
|
readonly property bool connectedFrameMode: CompositorService.usesConnectedFrameChromeForScreen(win.screen)
|
|
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 int inlineExpandDuration: Theme.notificationInlineExpandDuration
|
|
readonly property int inlineCollapseDuration: Theme.notificationInlineCollapseDuration
|
|
property bool inlineHeightAnimating: false
|
|
|
|
WindowBlur {
|
|
targetWindow: win
|
|
readonly property real s: Math.min(1, content.scale) * Math.max(0, content.opacity)
|
|
readonly property real innerW: Math.max(0, content.width - content.cardInset * 2)
|
|
readonly property real innerH: Math.max(0, content.height - content.cardInset * 2)
|
|
blurX: content.x + content.cardInset + swipeTx.x + tx.x + innerW * (1 - s) * 0.5
|
|
blurY: content.y + content.cardInset + swipeTx.y + tx.y + innerH * (1 - s) * 0.5
|
|
blurWidth: !win._finalized && !win.connectedFrameMode ? innerW * s : 0
|
|
blurHeight: !win._finalized && !win.connectedFrameMode ? innerH * s : 0
|
|
blurRadius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius
|
|
}
|
|
|
|
WlrLayershell.namespace: "dms:notification-popup"
|
|
|
|
required property var notificationData
|
|
required property string notificationId
|
|
readonly property bool hasValidData: notificationData && notificationData.notification
|
|
readonly property alias hovered: cardHoverHandler.hovered
|
|
readonly property alias swipeActive: content.swipeActive
|
|
readonly property alias swipeDismissing: content.swipeDismissing
|
|
readonly property bool swipeDismissTowardEdge: {
|
|
if (content.swipeDismissing)
|
|
return _swipeDismissesTowardFrameEdge();
|
|
if (content.swipeActive)
|
|
return content.swipeOffset * _frameEdgeSwipeDirection() > 0;
|
|
return false;
|
|
}
|
|
property int screenY: 0
|
|
property bool exiting: false
|
|
property bool _isDestroying: false
|
|
property bool _finalized: false
|
|
property real _lastReportedAlignedHeight: -1
|
|
property real _storedTopMargin: 0
|
|
property real _storedBottomMargin: 0
|
|
property bool _inlineGeometryReady: false
|
|
readonly property bool contextMenuActive: transientSurfaces.active
|
|
|
|
TransientSurfaceTracker {
|
|
id: transientSurfaces
|
|
}
|
|
readonly property bool directionalEffect: Theme.isDirectionalEffect
|
|
readonly property bool depthEffect: Theme.isDepthEffect
|
|
readonly property real entryTravel: {
|
|
const base = Math.abs(Theme.effectAnimOffset);
|
|
if (directionalEffect) {
|
|
if (isCenterPosition)
|
|
return Math.max(base, Math.round(content.height * 1.1));
|
|
return Math.max(base, Math.round(content.width * 0.95));
|
|
}
|
|
if (depthEffect)
|
|
return Math.max(base, 44);
|
|
return base;
|
|
}
|
|
readonly property real exitTravel: {
|
|
if (directionalEffect) {
|
|
if (isCenterPosition)
|
|
return Math.max(1, content.height);
|
|
return Math.max(1, content.width);
|
|
}
|
|
if (depthEffect)
|
|
return Math.round(entryTravel * 1.35);
|
|
return Anims.slidePx;
|
|
}
|
|
readonly property string clearText: I18n.tr("Dismiss")
|
|
property bool descriptionExpanded: false
|
|
readonly property bool hasExpandableBody: (notificationData?.htmlBody || "").replace(/<[^>]*>/g, "").trim().length > 0
|
|
onDescriptionExpandedChanged: {
|
|
if (connectedFrameMode)
|
|
popupChromeGeometryChanged();
|
|
}
|
|
|
|
readonly property bool compactMode: SettingsData.notificationCompactMode
|
|
readonly property real cardPadding: compactMode ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding
|
|
readonly property real popupIconSize: compactMode ? Theme.notificationIconSizeCompact : Theme.notificationIconSizeNormal
|
|
readonly property real contentSpacing: compactMode ? Theme.spacingXS : Theme.spacingS
|
|
readonly property real contentBottomClearance: 8
|
|
readonly property real actionButtonHeight: compactMode ? 20 : 24
|
|
readonly property real collapsedContentHeight: Math.max(popupIconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)) + contentBottomClearance
|
|
readonly property real privacyCollapsedContentHeight: Math.max(popupIconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2) + contentBottomClearance
|
|
readonly property real basePopupHeight: cardPadding * 2 + collapsedContentHeight + actionButtonHeight + contentSpacing
|
|
readonly property real basePopupHeightPrivacy: cardPadding * 2 + privacyCollapsedContentHeight + actionButtonHeight + contentSpacing
|
|
|
|
signal entered
|
|
signal exitStarted
|
|
signal exitFinished
|
|
signal popupHeightChanged
|
|
signal popupChromeGeometryChanged
|
|
|
|
function startExit() {
|
|
if (exiting || _isDestroying) {
|
|
return;
|
|
}
|
|
closeTransientUi();
|
|
exiting = true;
|
|
exitStarted();
|
|
popupChromeGeometryChanged();
|
|
exitAnim.restart();
|
|
exitWatchdog.restart();
|
|
if (NotificationService.removeFromVisibleNotifications)
|
|
NotificationService.removeFromVisibleNotifications(win.notificationData);
|
|
}
|
|
|
|
function forceExit() {
|
|
if (_isDestroying) {
|
|
return;
|
|
}
|
|
closeTransientUi();
|
|
_isDestroying = true;
|
|
exiting = true;
|
|
visible = false;
|
|
exitWatchdog.stop();
|
|
finalizeExit("forced");
|
|
}
|
|
|
|
function finalizeExit(reason) {
|
|
if (_finalized) {
|
|
return;
|
|
}
|
|
|
|
closeTransientUi();
|
|
_finalized = true;
|
|
_isDestroying = true;
|
|
exitWatchdog.stop();
|
|
wrapperConn.enabled = false;
|
|
wrapperConn.target = null;
|
|
win.exitFinished();
|
|
}
|
|
|
|
function closeTransientUi() {
|
|
transientSurfaces.closeAll();
|
|
popupContextMenuLoader.active = false;
|
|
}
|
|
|
|
function dismissPopupReliably() {
|
|
if (!notificationData || win.exiting || win._isDestroying)
|
|
return;
|
|
if (notificationData.timer)
|
|
notificationData.timer.stop();
|
|
notificationData.popup = false;
|
|
// Fallback if wrapperConn.onPopupChanged doesn't reach startExit.
|
|
Qt.callLater(() => {
|
|
if (!win.exiting && !win._isDestroying)
|
|
startExit();
|
|
});
|
|
}
|
|
|
|
visible: !_finalized
|
|
WlrLayershell.layer: {
|
|
const shouldUseOverlay = notificationData && (SettingsData.notificationOverlayEnabled || notificationData.urgency === NotificationUrgency.Critical);
|
|
const fallback = shouldUseOverlay ? WlrLayer.Overlay : WlrLayer.Top;
|
|
return LayerShell.fromEnv("DMS_NOTIFICATION_LAYER", fallback);
|
|
}
|
|
WlrLayershell.exclusiveZone: -1
|
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
|
color: "transparent"
|
|
readonly property real contentImplicitWidth: screen ? Math.min(400, Math.max(320, screen.width * 0.23)) : 380
|
|
readonly property real contentImplicitHeight: {
|
|
if (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded)
|
|
return basePopupHeightPrivacy;
|
|
if (!descriptionExpanded)
|
|
return basePopupHeight;
|
|
const bodyTextHeight = expandedBodyMeasure.contentHeight || bodyText.contentHeight || 0;
|
|
const collapsedBodyHeight = Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2);
|
|
if (bodyTextHeight > collapsedBodyHeight + 2)
|
|
return basePopupHeight + bodyTextHeight - collapsedBodyHeight;
|
|
return basePopupHeight;
|
|
}
|
|
readonly property real targetAlignedHeight: Theme.px(Math.max(0, contentImplicitHeight), dpr)
|
|
property real renderedAlignedHeight: targetAlignedHeight
|
|
property real allocatedAlignedHeight: targetAlignedHeight
|
|
readonly property bool inlineGeometryGrowing: targetAlignedHeight >= renderedAlignedHeight
|
|
readonly property bool contentAnchorsTop: isTopCenter || SettingsData.notificationPopupPosition === SettingsData.Position.Top || SettingsData.notificationPopupPosition === SettingsData.Position.Left
|
|
readonly property real renderedContentOffsetY: contentAnchorsTop ? 0 : Math.max(0, allocatedAlignedHeight - renderedAlignedHeight)
|
|
implicitWidth: contentImplicitWidth + (windowShadowPad * 2)
|
|
implicitHeight: allocatedAlignedHeight + (windowShadowPad * 2)
|
|
|
|
function inlineMotionDuration(growing) {
|
|
return growing ? inlineExpandDuration : inlineCollapseDuration;
|
|
}
|
|
|
|
function syncInlineTargetHeight() {
|
|
const target = Math.max(0, Number(targetAlignedHeight));
|
|
if (isNaN(target))
|
|
return;
|
|
|
|
if (!_inlineGeometryReady) {
|
|
renderedHeightAnim.stop();
|
|
renderedAlignedHeight = target;
|
|
allocatedAlignedHeight = target;
|
|
_lastReportedAlignedHeight = target;
|
|
return;
|
|
}
|
|
|
|
const currentRendered = Math.max(0, Number(renderedAlignedHeight));
|
|
const nextAllocation = Math.max(target, currentRendered, allocatedAlignedHeight);
|
|
if (Math.abs(nextAllocation - allocatedAlignedHeight) >= 0.5)
|
|
allocatedAlignedHeight = nextAllocation;
|
|
|
|
if (Math.abs(target - renderedAlignedHeight) < 0.5) {
|
|
finishInlineHeightAnimation();
|
|
return;
|
|
}
|
|
|
|
renderedAlignedHeight = target;
|
|
if (connectedFrameMode)
|
|
popupChromeGeometryChanged();
|
|
if (inlineMotionDuration(target >= currentRendered) <= 0)
|
|
Qt.callLater(() => finishInlineHeightAnimation());
|
|
}
|
|
|
|
function finishInlineHeightAnimation() {
|
|
const target = Math.max(0, Number(targetAlignedHeight));
|
|
if (isNaN(target))
|
|
return;
|
|
if (Math.abs(renderedAlignedHeight - target) >= 0.5)
|
|
renderedAlignedHeight = target;
|
|
if (Math.abs(allocatedAlignedHeight - target) >= 0.5)
|
|
allocatedAlignedHeight = target;
|
|
_lastReportedAlignedHeight = renderedAlignedHeight;
|
|
popupHeightChanged();
|
|
if (connectedFrameMode)
|
|
popupChromeGeometryChanged();
|
|
}
|
|
|
|
onTargetAlignedHeightChanged: syncInlineTargetHeight()
|
|
onAllocatedAlignedHeightChanged: {
|
|
if (connectedFrameMode)
|
|
popupChromeGeometryChanged();
|
|
}
|
|
|
|
Behavior on renderedAlignedHeight {
|
|
enabled: !win.exiting && !win._isDestroying
|
|
NumberAnimation {
|
|
id: renderedHeightAnim
|
|
duration: win.inlineMotionDuration(win.inlineGeometryGrowing)
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: win.inlineGeometryGrowing ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve
|
|
onRunningChanged: win.inlineHeightAnimating = running
|
|
onFinished: win.finishInlineHeightAnimation()
|
|
}
|
|
}
|
|
|
|
onHasValidDataChanged: {
|
|
if (!hasValidData && !exiting && !_isDestroying) {
|
|
forceExit();
|
|
}
|
|
}
|
|
Component.onCompleted: {
|
|
renderedHeightAnim.stop();
|
|
renderedAlignedHeight = targetAlignedHeight;
|
|
allocatedAlignedHeight = targetAlignedHeight;
|
|
_inlineGeometryReady = true;
|
|
_lastReportedAlignedHeight = renderedAlignedHeight;
|
|
_storedTopMargin = getTopMargin();
|
|
_storedBottomMargin = getBottomMargin();
|
|
if (SettingsData.notificationPopupPrivacyMode)
|
|
descriptionExpanded = false;
|
|
if (hasValidData) {
|
|
Qt.callLater(() => enterX.restart());
|
|
} else {
|
|
forceExit();
|
|
}
|
|
}
|
|
onNotificationDataChanged: {
|
|
if (!_isDestroying) {
|
|
closeTransientUi();
|
|
if (SettingsData.notificationPopupPrivacyMode)
|
|
descriptionExpanded = false;
|
|
wrapperConn.target = win.notificationData || null;
|
|
notificationConn.target = (win.notificationData && win.notificationData.notification && win.notificationData.notification.Retainable) || null;
|
|
}
|
|
}
|
|
onEntered: {
|
|
if (!_isDestroying) {
|
|
enterDelay.start();
|
|
}
|
|
}
|
|
Component.onDestruction: {
|
|
closeTransientUi();
|
|
_isDestroying = true;
|
|
exitWatchdog.stop();
|
|
if (notificationData && notificationData.timer) {
|
|
notificationData.timer.stop();
|
|
}
|
|
}
|
|
|
|
property bool isTopCenter: SettingsData.notificationPopupPosition === -1
|
|
property bool isBottomCenter: SettingsData.notificationPopupPosition === SettingsData.Position.BottomCenter
|
|
property bool isCenterPosition: isTopCenter || isBottomCenter
|
|
readonly property real maxPopupShadowBlurPx: Math.max((Theme.elevationLevel3 && Theme.elevationLevel3.blurPx !== undefined) ? Theme.elevationLevel3.blurPx : 12, (Theme.elevationLevel4 && Theme.elevationLevel4.blurPx !== undefined) ? Theme.elevationLevel4.blurPx : 16)
|
|
readonly property real maxPopupShadowOffsetXPx: Math.max(Math.abs(Theme.elevationOffsetX(Theme.elevationLevel3)), Math.abs(Theme.elevationOffsetX(Theme.elevationLevel4)))
|
|
readonly property real maxPopupShadowOffsetYPx: Math.max(Math.abs(Theme.elevationOffsetY(Theme.elevationLevel3, 6)), Math.abs(Theme.elevationOffsetY(Theme.elevationLevel4, 8)))
|
|
readonly property bool popupWindowShadowActive: Theme.elevationEnabled && SettingsData.notificationPopupShadowEnabled && !connectedFrameMode
|
|
readonly property real windowShadowPad: popupWindowShadowActive ? Theme.snap(Math.max(16, maxPopupShadowBlurPx + Math.max(maxPopupShadowOffsetXPx, maxPopupShadowOffsetYPx) + 8), dpr) : 0
|
|
|
|
anchors.top: true
|
|
anchors.left: true
|
|
anchors.bottom: false
|
|
anchors.right: false
|
|
|
|
mask: contentInputMask
|
|
|
|
Region {
|
|
id: contentInputMask
|
|
item: contentMaskRect
|
|
}
|
|
|
|
Item {
|
|
id: contentMaskRect
|
|
visible: false
|
|
x: content.x
|
|
y: content.y
|
|
width: alignedWidth
|
|
height: alignedHeight
|
|
}
|
|
|
|
margins {
|
|
top: getWindowTopMargin()
|
|
bottom: 0
|
|
left: getWindowLeftMargin()
|
|
right: 0
|
|
}
|
|
|
|
function getBarInfo() {
|
|
if (!screen)
|
|
return {
|
|
topBar: 0,
|
|
bottomBar: 0,
|
|
leftBar: 0,
|
|
rightBar: 0
|
|
};
|
|
return SettingsData.getAdjacentBarInfo(screen, SettingsData.notificationPopupPosition, {
|
|
id: "notification-popup",
|
|
screenPreferences: [screen.name],
|
|
autoHide: false
|
|
});
|
|
}
|
|
|
|
function _frameEdgeInset(side) {
|
|
if (!screen)
|
|
return 0;
|
|
const raw = SettingsData.frameEdgeInsetForSide(screen, side);
|
|
return Math.max(0, Math.round(Theme.px(raw, dpr)));
|
|
}
|
|
|
|
readonly property bool frameVisibleWithoutConnectedChrome: CompositorService.frameWindowVisibleForScreen(screen) && !connectedFrameMode
|
|
|
|
// Frame visible without connected chrome. frameEdgeInset is the full bar/frame inset.
|
|
function _frameGapMargin(side) {
|
|
return _frameEdgeInset(side) + Theme.popupDistance;
|
|
}
|
|
|
|
function getTopMargin() {
|
|
const popupPos = SettingsData.notificationPopupPosition;
|
|
const isTop = isTopCenter || popupPos === SettingsData.Position.Top || popupPos === SettingsData.Position.Left;
|
|
if (!isTop)
|
|
return 0;
|
|
|
|
if (connectedFrameMode) {
|
|
const cornerClear = (isCenterPosition || SettingsData.frameCloseGaps) ? 0 : (Theme.px(SettingsData.frameRounding, dpr) + Theme.px(Theme.connectedCornerRadius, dpr));
|
|
return _frameEdgeInset("top") + cornerClear + screenY;
|
|
}
|
|
if (frameVisibleWithoutConnectedChrome)
|
|
return _frameGapMargin("top") + screenY;
|
|
const barInfo = getBarInfo();
|
|
const base = barInfo.topBar > 0 ? barInfo.topBar : Theme.popupDistance;
|
|
return base + screenY;
|
|
}
|
|
|
|
function getBottomMargin() {
|
|
const popupPos = SettingsData.notificationPopupPosition;
|
|
const isBottom = isBottomCenter || popupPos === SettingsData.Position.Bottom || popupPos === SettingsData.Position.Right;
|
|
if (!isBottom)
|
|
return 0;
|
|
|
|
if (connectedFrameMode) {
|
|
const cornerClear = (isCenterPosition || SettingsData.frameCloseGaps) ? 0 : (Theme.px(SettingsData.frameRounding, dpr) + Theme.px(Theme.connectedCornerRadius, dpr));
|
|
return _frameEdgeInset("bottom") + cornerClear + screenY;
|
|
}
|
|
if (frameVisibleWithoutConnectedChrome)
|
|
return _frameGapMargin("bottom") + screenY;
|
|
const barInfo = getBarInfo();
|
|
const base = barInfo.bottomBar > 0 ? barInfo.bottomBar : Theme.popupDistance;
|
|
return base + screenY;
|
|
}
|
|
|
|
function getLeftMargin() {
|
|
if (isCenterPosition)
|
|
return screen ? (screen.width - alignedWidth) / 2 : 0;
|
|
|
|
const popupPos = SettingsData.notificationPopupPosition;
|
|
const isLeft = popupPos === SettingsData.Position.Left || popupPos === SettingsData.Position.Bottom;
|
|
if (!isLeft)
|
|
return 0;
|
|
|
|
if (connectedFrameMode)
|
|
return _frameEdgeInset("left");
|
|
if (frameVisibleWithoutConnectedChrome)
|
|
return _frameGapMargin("left");
|
|
const barInfo = getBarInfo();
|
|
return barInfo.leftBar > 0 ? barInfo.leftBar : Theme.popupDistance;
|
|
}
|
|
|
|
function getRightMargin() {
|
|
if (isCenterPosition)
|
|
return 0;
|
|
|
|
const popupPos = SettingsData.notificationPopupPosition;
|
|
const isRight = popupPos === SettingsData.Position.Top || popupPos === SettingsData.Position.Right;
|
|
if (!isRight)
|
|
return 0;
|
|
|
|
if (connectedFrameMode)
|
|
return _frameEdgeInset("right");
|
|
if (frameVisibleWithoutConnectedChrome)
|
|
return _frameGapMargin("right");
|
|
const barInfo = getBarInfo();
|
|
return barInfo.rightBar > 0 ? barInfo.rightBar : Theme.popupDistance;
|
|
}
|
|
|
|
function getContentX() {
|
|
if (!screen)
|
|
return 0;
|
|
|
|
const popupPos = SettingsData.notificationPopupPosition;
|
|
const barLeft = getLeftMargin();
|
|
const barRight = getRightMargin();
|
|
|
|
if (isCenterPosition)
|
|
return Theme.snap((screen.width - alignedWidth) / 2, dpr);
|
|
if (popupPos === SettingsData.Position.Left || popupPos === SettingsData.Position.Bottom)
|
|
return Theme.snap(barLeft, dpr);
|
|
return Theme.snap(screen.width - alignedWidth - barRight, dpr);
|
|
}
|
|
|
|
function getAllocatedContentY() {
|
|
if (!screen)
|
|
return 0;
|
|
|
|
const popupPos = SettingsData.notificationPopupPosition;
|
|
const barTop = getTopMargin();
|
|
const barBottom = getBottomMargin();
|
|
const isTop = isTopCenter || popupPos === SettingsData.Position.Top || popupPos === SettingsData.Position.Left;
|
|
if (isTop)
|
|
return Theme.snap(barTop, dpr);
|
|
return Theme.snap(screen.height - allocatedAlignedHeight - barBottom, dpr);
|
|
}
|
|
|
|
function getContentY() {
|
|
return Theme.snap(getAllocatedContentY() + renderedContentOffsetY, dpr);
|
|
}
|
|
|
|
function getWindowLeftMargin() {
|
|
if (!screen)
|
|
return 0;
|
|
return Theme.snap(getContentX() - windowShadowPad, dpr);
|
|
}
|
|
|
|
function getWindowTopMargin() {
|
|
if (!screen)
|
|
return 0;
|
|
return Theme.snap(getAllocatedContentY() - windowShadowPad, dpr);
|
|
}
|
|
|
|
function _swipeDismissTarget() {
|
|
return (content.swipeDismissDirection < 0 ? -1 : 1) * content.width;
|
|
}
|
|
|
|
function _frameEdgeSwipeDirection() {
|
|
const popupPos = SettingsData.notificationPopupPosition;
|
|
return (popupPos === SettingsData.Position.Left || popupPos === SettingsData.Position.Bottom) ? -1 : 1;
|
|
}
|
|
|
|
function _swipeDismissesTowardFrameEdge() {
|
|
return content.swipeDismissDirection === _frameEdgeSwipeDirection();
|
|
}
|
|
|
|
function popupChromeMotionActive() {
|
|
return popupChromeOpenProgress() < 1 || exiting || content.swipeActive || content.swipeDismissing || Math.abs(content.swipeOffset) > 0.5;
|
|
}
|
|
|
|
function popupLayoutReservesSlot() {
|
|
return !content.swipeDismissing;
|
|
}
|
|
|
|
function popupChromeReservesSlot() {
|
|
return !content.swipeDismissing;
|
|
}
|
|
|
|
function _chromeMotionOffset() {
|
|
return isCenterPosition ? tx.y : tx.x;
|
|
}
|
|
|
|
function _chromeCardTravel() {
|
|
return Math.max(1, isCenterPosition ? alignedHeight : alignedWidth);
|
|
}
|
|
|
|
function popupChromeOpenProgress() {
|
|
if (exiting || content.swipeDismissing)
|
|
return 1;
|
|
return Math.max(0, Math.min(1, 1 - Math.abs(_chromeMotionOffset()) / _chromeCardTravel()));
|
|
}
|
|
|
|
function popupChromeReleaseProgress() {
|
|
if (exiting) {
|
|
const exitRel = Math.max(0, Math.min(1, Math.abs(_chromeMotionOffset()) / _chromeCardTravel()));
|
|
if (content.swipeDismissing) {
|
|
const swipeRel = Math.max(0, Math.min(1, Math.abs(content.swipeOffset) / Math.max(1, content.swipeTravelDistance)));
|
|
return Math.max(exitRel, swipeRel);
|
|
}
|
|
return exitRel;
|
|
}
|
|
if (content.swipeDismissing)
|
|
return Math.max(0, Math.min(1, Math.abs(content.swipeOffset) / Math.max(1, content.swipeTravelDistance)));
|
|
if (content.swipeActive && content.swipeOffset * _frameEdgeSwipeDirection() > 0)
|
|
return Math.max(0, Math.min(1, Math.abs(content.swipeOffset) / Math.max(1, content.swipeTravelDistance)));
|
|
return 0;
|
|
}
|
|
|
|
function popupChromeFollowsCardMotion() {
|
|
return false;
|
|
}
|
|
|
|
function popupChromeMotionX() {
|
|
if (!popupChromeMotionActive() || isCenterPosition)
|
|
return 0;
|
|
const motion = content.swipeOffset + tx.x;
|
|
if (content.swipeDismissing && !_swipeDismissesTowardFrameEdge())
|
|
return exiting ? Theme.snap(tx.x, dpr) : 0;
|
|
if (content.swipeActive && motion * _frameEdgeSwipeDirection() < 0)
|
|
return 0;
|
|
return Theme.snap(motion, dpr);
|
|
}
|
|
|
|
function popupChromeMotionY() {
|
|
return popupChromeMotionActive() ? Theme.snap(tx.y, dpr) : 0;
|
|
}
|
|
|
|
readonly property bool screenValid: win.screen && !_isDestroying
|
|
readonly property real dpr: screenValid ? CompositorService.getScreenScale(win.screen) : 1
|
|
readonly property real alignedWidth: Theme.px(Math.max(0, implicitWidth - (windowShadowPad * 2)), dpr)
|
|
readonly property real alignedHeight: renderedAlignedHeight
|
|
onScreenYChanged: if (connectedFrameMode)
|
|
popupChromeGeometryChanged()
|
|
onScreenChanged: if (connectedFrameMode)
|
|
popupChromeGeometryChanged()
|
|
// Intentionally unconditional: Manager needs the signal when frame mode toggles off
|
|
onConnectedFrameModeChanged: popupChromeGeometryChanged()
|
|
onAlignedWidthChanged: if (connectedFrameMode)
|
|
popupChromeGeometryChanged()
|
|
onAlignedHeightChanged: if (connectedFrameMode)
|
|
popupChromeGeometryChanged()
|
|
|
|
Item {
|
|
id: content
|
|
|
|
x: Theme.snap(windowShadowPad, dpr)
|
|
y: Theme.snap(windowShadowPad + renderedContentOffsetY, dpr)
|
|
width: alignedWidth
|
|
height: alignedHeight
|
|
visible: !win._finalized && !chromeOnlyExit
|
|
transformOrigin: Item.Center
|
|
|
|
property real chromeScale: (!win.inlineHeightAnimating && cardHoverHandler.hovered) ? 1.01 : 1.0
|
|
|
|
Behavior on chromeScale {
|
|
NumberAnimation {
|
|
duration: Theme.shortDuration
|
|
easing.type: Theme.standardEasing
|
|
}
|
|
}
|
|
|
|
property real swipeOffset: 0
|
|
property real swipeDismissDirection: 1
|
|
property bool chromeOnlyExit: false
|
|
readonly property real dismissThreshold: width * 0.35
|
|
readonly property real swipeFadeStartRatio: 0.75
|
|
readonly property real swipeTravelDistance: width
|
|
readonly property real swipeFadeStartOffset: swipeTravelDistance * swipeFadeStartRatio
|
|
readonly property real swipeFadeDistance: Math.max(1, swipeTravelDistance - swipeFadeStartOffset)
|
|
readonly property bool swipeActive: swipeDragHandler.active
|
|
property bool swipeDismissing: false
|
|
onSwipeDismissingChanged: {
|
|
if (!win.connectedFrameMode)
|
|
return;
|
|
win.popupHeightChanged();
|
|
win.popupChromeGeometryChanged();
|
|
}
|
|
onSwipeOffsetChanged: {
|
|
if (win.connectedFrameMode)
|
|
win.popupChromeGeometryChanged();
|
|
}
|
|
|
|
readonly property bool shadowsAllowed: win.popupWindowShadowActive
|
|
readonly property var elevLevel: cardHoverHandler.hovered ? Theme.elevationLevel4 : Theme.elevationLevel3
|
|
readonly property real cardInset: Theme.snap(4, win.dpr)
|
|
readonly property real shadowRenderPadding: shadowsAllowed ? Theme.snap(Math.max(16, shadowBlurPx + Math.max(Math.abs(shadowOffsetX), Math.abs(shadowOffsetY)) + 8), win.dpr) : 0
|
|
property real shadowBlurPx: shadowsAllowed ? (elevLevel && elevLevel.blurPx !== undefined ? elevLevel.blurPx : 12) : 0
|
|
property real shadowOffsetX: shadowsAllowed ? Theme.elevationOffsetX(elevLevel) : 0
|
|
property real shadowOffsetY: shadowsAllowed ? Theme.elevationOffsetY(elevLevel, 6) : 0
|
|
|
|
Behavior on shadowBlurPx {
|
|
NumberAnimation {
|
|
duration: win.inlineHeightAnimating ? win.inlineExpandDuration : Theme.shortDuration
|
|
easing.type: Theme.standardEasing
|
|
}
|
|
}
|
|
|
|
Behavior on shadowOffsetX {
|
|
NumberAnimation {
|
|
duration: win.inlineHeightAnimating ? win.inlineExpandDuration : Theme.shortDuration
|
|
easing.type: Theme.standardEasing
|
|
}
|
|
}
|
|
|
|
Behavior on shadowOffsetY {
|
|
NumberAnimation {
|
|
duration: win.inlineHeightAnimating ? win.inlineExpandDuration : Theme.shortDuration
|
|
easing.type: Theme.standardEasing
|
|
}
|
|
}
|
|
|
|
ElevationShadow {
|
|
id: bgShadowLayer
|
|
anchors.fill: parent
|
|
anchors.margins: -content.shadowRenderPadding
|
|
scale: content.chromeScale
|
|
transformOrigin: Item.Center
|
|
level: content.elevLevel
|
|
fallbackOffset: 6
|
|
shadowBlurPx: content.shadowBlurPx
|
|
shadowOffsetX: content.shadowOffsetX
|
|
shadowOffsetY: content.shadowOffsetY
|
|
shadowColor: content.shadowsAllowed && content.elevLevel ? Theme.elevationShadowColor(content.elevLevel) : Theme.withAlpha(Theme.elevationShadowColor(content.elevLevel), 0)
|
|
shadowEnabled: !win._isDestroying && win.screenValid && content.shadowsAllowed && !win.connectedFrameMode
|
|
|
|
sourceX: content.shadowRenderPadding + content.cardInset
|
|
sourceY: content.shadowRenderPadding + content.cardInset
|
|
sourceWidth: Math.max(0, content.width - (content.cardInset * 2))
|
|
sourceHeight: Math.max(0, content.height - (content.cardInset * 2))
|
|
targetRadius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius
|
|
targetColor: win.connectedFrameMode ? Theme.floatingSurface : Theme.readableSurface
|
|
borderColor: win.notificationData && win.notificationData.urgency === NotificationUrgency.Critical ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.outline, 0.08)
|
|
borderWidth: win.notificationData && win.notificationData.urgency === NotificationUrgency.Critical ? 2 : 0
|
|
}
|
|
|
|
// Keep critical accent outside shadow rendering so connected mode still shows it.
|
|
Rectangle {
|
|
x: content.cardInset
|
|
y: content.cardInset
|
|
width: Math.max(0, content.width - content.cardInset * 2)
|
|
height: Math.max(0, content.height - content.cardInset * 2)
|
|
radius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius
|
|
visible: win.notificationData && win.notificationData.urgency === NotificationUrgency.Critical
|
|
opacity: 1
|
|
clip: true
|
|
scale: content.chromeScale
|
|
transformOrigin: Item.Center
|
|
|
|
gradient: Gradient {
|
|
orientation: Gradient.Horizontal
|
|
|
|
GradientStop {
|
|
position: 0
|
|
color: Theme.primary
|
|
}
|
|
|
|
GradientStop {
|
|
position: 0.02
|
|
color: Theme.primary
|
|
}
|
|
|
|
GradientStop {
|
|
position: 0.021
|
|
color: "transparent"
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
anchors.margins: content.cardInset
|
|
radius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius
|
|
color: "transparent"
|
|
border.color: win.connectedFrameMode ? Theme.withAlpha(BlurService.borderColor, 0) : BlurService.borderColor
|
|
border.width: win.connectedFrameMode ? 0 : BlurService.borderWidth
|
|
z: 100
|
|
scale: content.chromeScale
|
|
transformOrigin: Item.Center
|
|
}
|
|
|
|
Item {
|
|
id: backgroundContainer
|
|
anchors.fill: parent
|
|
anchors.margins: content.cardInset
|
|
clip: true
|
|
|
|
HoverHandler {
|
|
id: cardHoverHandler
|
|
}
|
|
|
|
Connections {
|
|
target: cardHoverHandler
|
|
function onHoveredChanged() {
|
|
if (!notificationData || win.exiting || win._isDestroying)
|
|
return;
|
|
if (cardHoverHandler.hovered) {
|
|
if (notificationData.timer)
|
|
notificationData.timer.stop();
|
|
} else if (!win.contextMenuActive && notificationData.popup && notificationData.timer) {
|
|
notificationData.timer.restart();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Timeout progress bar: drains as the dismiss timer runs; inset by
|
|
// the corner radius and frozen while hovered or during exit.
|
|
Rectangle {
|
|
id: timeoutBar
|
|
|
|
readonly property bool active: SettingsData.notificationShowTimeoutBar && notificationData && notificationData.timer && notificationData.timer.interval > 0
|
|
property real progress: 1
|
|
readonly property real surfaceRadius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius
|
|
|
|
visible: active && progress > 0
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: surfaceRadius
|
|
anchors.bottom: parent.bottom
|
|
width: Math.max(0, parent.width - surfaceRadius * 2) * progress
|
|
height: Math.max(2, Theme.snap(3, win.dpr))
|
|
radius: height / 2
|
|
z: 50
|
|
opacity: 0.9
|
|
color: notificationData && notificationData.urgency === NotificationUrgency.Critical ? Theme.error : Theme.primary
|
|
|
|
NumberAnimation {
|
|
id: progressAnim
|
|
target: timeoutBar
|
|
property: "progress"
|
|
from: 1
|
|
to: 0
|
|
duration: (notificationData && notificationData.timer && notificationData.timer.interval > 0) ? notificationData.timer.interval : 5000
|
|
running: timeoutBar.active && notificationData && notificationData.timer && notificationData.timer.running && !win.exiting
|
|
easing.type: Easing.Linear
|
|
}
|
|
|
|
// Reset to full on every (re)start, including an in-place
|
|
// restart on a deduped notification (running stays true, so the
|
|
// bound animation alone wouldn't re-fire).
|
|
Connections {
|
|
target: timeoutBar.active ? notificationData.timer : null
|
|
function onRunningChanged() {
|
|
if (notificationData && notificationData.timer && notificationData.timer.running && !win.exiting) {
|
|
timeoutBar.progress = 1;
|
|
progressAnim.restart();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
LayoutMirroring.enabled: I18n.isRtl
|
|
LayoutMirroring.childrenInherit: true
|
|
|
|
StyledText {
|
|
id: expandedBodyMeasure
|
|
|
|
visible: false
|
|
width: Math.max(0, backgroundContainer.width - Theme.spacingL - (Theme.spacingL + Theme.notificationHoverRevealMargin) - popupIconSize - Theme.spacingM)
|
|
text: notificationData ? (notificationData.htmlBody || "") : ""
|
|
textFormat: Text.StyledText
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
elide: Text.ElideNone
|
|
horizontalAlignment: Text.AlignLeft
|
|
maximumLineCount: -1
|
|
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
|
}
|
|
|
|
Item {
|
|
id: notificationContent
|
|
|
|
readonly property real expandedTextHeight: expandedBodyMeasure.contentHeight || bodyText.contentHeight || 0
|
|
readonly property real collapsedBodyHeight: Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)
|
|
readonly property real effectiveCollapsedHeight: (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) ? win.privacyCollapsedContentHeight : win.collapsedContentHeight
|
|
readonly property real extraHeight: (descriptionExpanded && expandedTextHeight > collapsedBodyHeight + 2) ? (expandedTextHeight - collapsedBodyHeight) : 0
|
|
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.topMargin: cardPadding
|
|
anchors.leftMargin: Theme.spacingL
|
|
anchors.rightMargin: Theme.spacingL + Theme.notificationHoverRevealMargin
|
|
height: effectiveCollapsedHeight + extraHeight
|
|
clip: SettingsData.notificationPopupPrivacyMode && !descriptionExpanded
|
|
|
|
DankCircularImage {
|
|
id: iconContainer
|
|
cacheImages: false
|
|
|
|
readonly property string rawImage: notificationData?.image || ""
|
|
readonly property string iconFromImage: {
|
|
if (rawImage.startsWith("image://icon/"))
|
|
return rawImage.substring(13);
|
|
return "";
|
|
}
|
|
readonly property bool imageHasSpecialPrefix: {
|
|
const icon = iconFromImage;
|
|
return icon.startsWith("material:") || icon.startsWith("svg:") || icon.startsWith("unicode:") || icon.startsWith("image:");
|
|
}
|
|
readonly property bool hasNotificationImage: rawImage !== "" && (!rawImage.startsWith("image://icon/") || iconFromImage.startsWith("/"))
|
|
readonly property bool needsImagePersist: hasNotificationImage && (rawImage.startsWith("image://qsimage/") || iconFromImage.startsWith("/")) && !notificationData.persistedImagePath
|
|
|
|
width: popupIconSize
|
|
height: popupIconSize
|
|
anchors.left: parent.left
|
|
anchors.top: parent.top
|
|
anchors.topMargin: {
|
|
if (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) {
|
|
const headerSummary = Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2;
|
|
return Math.max(0, headerSummary / 2 - popupIconSize / 2);
|
|
}
|
|
if (descriptionExpanded)
|
|
return Math.max(0, Theme.fontSizeSmall * 1.2 + (Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)) / 2 - popupIconSize / 2);
|
|
return Math.max(0, Theme.fontSizeSmall * 1.2 + (textContainer.height - Theme.fontSizeSmall * 1.2) / 2 - popupIconSize / 2);
|
|
}
|
|
|
|
imageSource: {
|
|
if (!notificationData)
|
|
return "";
|
|
if (hasNotificationImage)
|
|
return notificationData.cleanImage || "";
|
|
if (imageHasSpecialPrefix)
|
|
return "";
|
|
const appIcon = notificationData.appIcon;
|
|
if (!appIcon)
|
|
return "";
|
|
if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/"))
|
|
return appIcon;
|
|
return "";
|
|
}
|
|
|
|
hasImage: hasNotificationImage
|
|
fallbackIcon: {
|
|
if (imageHasSpecialPrefix)
|
|
return iconFromImage;
|
|
return notificationData?.appIcon || iconFromImage || "";
|
|
}
|
|
fallbackText: {
|
|
const appName = notificationData?.appName || "?";
|
|
return appName.charAt(0).toUpperCase();
|
|
}
|
|
|
|
onImageStatusChanged: {
|
|
if (imageStatus === Image.Ready && needsImagePersist) {
|
|
const cachePath = NotificationService.getImageCachePath(notificationData);
|
|
saveImageToFile(cachePath);
|
|
}
|
|
}
|
|
|
|
onImageSaved: filePath => {
|
|
if (!notificationData)
|
|
return;
|
|
notificationData.persistedImagePath = filePath;
|
|
const wrapperId = notificationData.notification?.id?.toString() || "";
|
|
if (wrapperId)
|
|
NotificationService.updateHistoryImage(wrapperId, filePath);
|
|
}
|
|
}
|
|
|
|
Column {
|
|
id: textContainer
|
|
|
|
anchors.left: iconContainer.right
|
|
anchors.leftMargin: Theme.spacingM
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
spacing: Theme.notificationContentSpacing
|
|
|
|
Row {
|
|
id: headerRow
|
|
width: parent.width
|
|
spacing: Theme.spacingXS
|
|
visible: headerAppNameText.text.length > 0 || headerTimeText.text.length > 0
|
|
|
|
StyledText {
|
|
id: headerAppNameText
|
|
text: notificationData ? (notificationData.appName || "") : ""
|
|
color: Theme.surfaceTextMedium
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
font.weight: Font.Normal
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 1
|
|
width: Math.min(implicitWidth, parent.width - headerSeparator.implicitWidth - headerTimeText.implicitWidth - parent.spacing * 2)
|
|
}
|
|
|
|
StyledText {
|
|
id: headerSeparator
|
|
text: (headerAppNameText.text.length > 0 && headerTimeText.text.length > 0) ? " • " : ""
|
|
color: Theme.surfaceTextMedium
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
font.weight: Font.Normal
|
|
}
|
|
|
|
StyledText {
|
|
id: headerTimeText
|
|
text: notificationData ? (notificationData.timeStr || "") : ""
|
|
color: Theme.surfaceTextMedium
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
font.weight: Font.Normal
|
|
}
|
|
}
|
|
|
|
StyledText {
|
|
text: notificationData ? (notificationData.summary || "") : ""
|
|
color: Theme.surfaceText
|
|
font.pixelSize: SettingsData.notificationSummaryFontSize || Theme.fontSizeMedium
|
|
font.weight: Font.Medium
|
|
width: parent.width
|
|
elide: Text.ElideRight
|
|
horizontalAlignment: Text.AlignLeft
|
|
maximumLineCount: 1
|
|
visible: text.length > 0
|
|
}
|
|
|
|
StyledText {
|
|
id: bodyText
|
|
property bool hasMoreText: truncated
|
|
|
|
text: notificationData ? (notificationData.htmlBody || "") : ""
|
|
textFormat: Text.StyledText
|
|
color: Theme.surfaceVariantText
|
|
font.pixelSize: SettingsData.notificationBodyFontSize || Theme.fontSizeSmall
|
|
width: parent.width
|
|
elide: descriptionExpanded ? Text.ElideNone : Text.ElideRight
|
|
horizontalAlignment: Text.AlignLeft
|
|
maximumLineCount: descriptionExpanded ? -1 : (compactMode ? 1 : 2)
|
|
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
|
|
visible: text.length > 0
|
|
opacity: (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) ? 0 : 1
|
|
linkColor: Theme.primary
|
|
onLinkActivated: link => Qt.openUrlExternally(link)
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : (bodyText.hasMoreText || descriptionExpanded) ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
|
|
onClicked: mouse => {
|
|
if (!parent.hoveredLink && (bodyText.hasMoreText || descriptionExpanded))
|
|
win.descriptionExpanded = !win.descriptionExpanded;
|
|
}
|
|
|
|
propagateComposedEvents: false
|
|
onPressed: mouse => {
|
|
if (parent.hoveredLink)
|
|
mouse.accepted = false;
|
|
}
|
|
onReleased: mouse => {
|
|
if (parent.hoveredLink)
|
|
mouse.accepted = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
StyledText {
|
|
text: I18n.tr("Message Content", "notification privacy mode placeholder")
|
|
color: Theme.surfaceVariantText
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
width: parent.width
|
|
visible: SettingsData.notificationPopupPrivacyMode && !descriptionExpanded && win.hasExpandableBody
|
|
}
|
|
}
|
|
}
|
|
|
|
DankActionButton {
|
|
id: closeButton
|
|
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.topMargin: cardPadding
|
|
anchors.rightMargin: Theme.spacingL
|
|
iconName: "close"
|
|
iconSize: compactMode ? 14 : 16
|
|
buttonSize: compactMode ? 20 : 24
|
|
z: 15
|
|
|
|
onClicked: {
|
|
dismissPopupReliably();
|
|
}
|
|
}
|
|
|
|
DankActionButton {
|
|
id: expandButton
|
|
|
|
anchors.right: closeButton.left
|
|
anchors.rightMargin: Theme.spacingXS
|
|
anchors.top: parent.top
|
|
anchors.topMargin: cardPadding
|
|
iconName: descriptionExpanded ? "expand_less" : "expand_more"
|
|
iconSize: compactMode ? 14 : 16
|
|
buttonSize: compactMode ? 20 : 24
|
|
z: 15
|
|
visible: SettingsData.notificationPopupPrivacyMode && win.hasExpandableBody
|
|
|
|
onClicked: {
|
|
if (win.hasExpandableBody)
|
|
win.descriptionExpanded = !win.descriptionExpanded;
|
|
}
|
|
}
|
|
|
|
Row {
|
|
visible: cardHoverHandler.hovered
|
|
opacity: visible ? 1 : 0
|
|
anchors.right: clearButton.visible ? clearButton.left : parent.right
|
|
anchors.rightMargin: clearButton.visible ? contentSpacing : Theme.spacingL
|
|
anchors.top: notificationContent.bottom
|
|
anchors.topMargin: contentSpacing
|
|
spacing: contentSpacing
|
|
z: 20
|
|
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: Theme.shortDuration
|
|
easing.type: Theme.standardEasing
|
|
}
|
|
}
|
|
|
|
Repeater {
|
|
model: notificationData ? (notificationData.actions || []) : []
|
|
|
|
Rectangle {
|
|
property bool isHovered: false
|
|
|
|
width: Math.max(actionText.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
|
|
height: actionButtonHeight
|
|
radius: Theme.notificationButtonCornerRadius
|
|
color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : Theme.withAlpha(Theme.primary, 0)
|
|
|
|
StyledText {
|
|
id: actionText
|
|
|
|
text: modelData.text || "Open"
|
|
color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
font.weight: Font.Medium
|
|
anchors.centerIn: parent
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
acceptedButtons: Qt.LeftButton
|
|
onEntered: parent.isHovered = true
|
|
onExited: parent.isHovered = false
|
|
onClicked: {
|
|
if (modelData && modelData.invoke)
|
|
modelData.invoke();
|
|
dismissPopupReliably();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: clearButton
|
|
|
|
property bool isHovered: false
|
|
readonly property int actionCount: notificationData ? (notificationData.actions || []).length : 0
|
|
|
|
visible: actionCount < 3 && cardHoverHandler.hovered
|
|
opacity: visible ? 1 : 0
|
|
Behavior on opacity {
|
|
NumberAnimation {
|
|
duration: Theme.shortDuration
|
|
easing.type: Theme.standardEasing
|
|
}
|
|
}
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: Theme.spacingL
|
|
anchors.top: notificationContent.bottom
|
|
anchors.topMargin: contentSpacing
|
|
width: Math.max(clearTextLabel.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
|
|
height: actionButtonHeight
|
|
radius: Theme.notificationButtonCornerRadius
|
|
color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : Theme.withAlpha(Theme.primary, 0)
|
|
z: 20
|
|
|
|
StyledText {
|
|
id: clearTextLabel
|
|
|
|
text: win.clearText
|
|
color: clearButton.isHovered ? Theme.primary : Theme.surfaceVariantText
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
font.weight: Font.Medium
|
|
anchors.centerIn: parent
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
acceptedButtons: Qt.LeftButton
|
|
onEntered: clearButton.isHovered = true
|
|
onExited: clearButton.isHovered = false
|
|
onClicked: {
|
|
if (notificationData && !win.exiting)
|
|
NotificationService.dismissNotification(notificationData);
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: cardHoverArea
|
|
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
cursorShape: Qt.PointingHandCursor
|
|
propagateComposedEvents: true
|
|
z: -1
|
|
onClicked: mouse => {
|
|
if (!notificationData || win.exiting)
|
|
return;
|
|
if (mouse.button === Qt.RightButton) {
|
|
popupContextMenuLoader.active = true;
|
|
const menu = popupContextMenuLoader.item;
|
|
if (menu) {
|
|
const p = mapToItem(null, mouse.x, mouse.y);
|
|
menu.showAt(win.margins.left + p.x, win.margins.top + p.y, win.screen);
|
|
}
|
|
} else if (mouse.button === Qt.LeftButton) {
|
|
const canExpand = bodyText.hasMoreText || win.descriptionExpanded || (SettingsData.notificationPopupPrivacyMode && win.hasExpandableBody);
|
|
if (canExpand) {
|
|
win.descriptionExpanded = !win.descriptionExpanded;
|
|
} else if (notificationData.actions && notificationData.actions.length > 0) {
|
|
notificationData.actions[0].invoke();
|
|
NotificationService.dismissNotification(notificationData);
|
|
} else {
|
|
dismissPopupReliably();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
DragHandler {
|
|
id: swipeDragHandler
|
|
target: null
|
|
xAxis.enabled: true
|
|
yAxis.enabled: false
|
|
|
|
onActiveChanged: {
|
|
if (active || win.exiting || content.swipeDismissing)
|
|
return;
|
|
|
|
if (Math.abs(content.swipeOffset) > content.dismissThreshold) {
|
|
content.swipeDismissDirection = content.swipeOffset < 0 ? -1 : 1;
|
|
content.swipeDismissing = true;
|
|
swipeDismissAnim.start();
|
|
} else {
|
|
content.swipeOffset = 0;
|
|
}
|
|
}
|
|
|
|
onTranslationChanged: {
|
|
if (win.exiting || content.swipeDismissing)
|
|
return;
|
|
|
|
content.swipeOffset = translation.x;
|
|
}
|
|
}
|
|
|
|
opacity: {
|
|
const swipeAmount = Math.abs(content.swipeOffset);
|
|
if (swipeAmount <= content.swipeFadeStartOffset)
|
|
return 1;
|
|
const fadeProgress = (swipeAmount - content.swipeFadeStartOffset) / content.swipeFadeDistance;
|
|
return Math.max(0, 1 - fadeProgress);
|
|
}
|
|
|
|
Behavior on opacity {
|
|
enabled: !content.swipeActive && !content.swipeDismissing
|
|
NumberAnimation {
|
|
duration: Theme.shortDuration
|
|
}
|
|
}
|
|
|
|
Behavior on swipeOffset {
|
|
enabled: !content.swipeActive && !content.swipeDismissing
|
|
NumberAnimation {
|
|
duration: Theme.notificationExitDuration
|
|
easing.type: Theme.standardEasing
|
|
}
|
|
}
|
|
|
|
NumberAnimation {
|
|
id: swipeDismissAnim
|
|
target: content
|
|
property: "swipeOffset"
|
|
to: win._swipeDismissTarget()
|
|
duration: Theme.notificationExitDuration
|
|
easing.type: Easing.OutCubic
|
|
onStopped: {
|
|
const inwardConnectedExit = win.connectedFrameMode && !win.isCenterPosition && !win._swipeDismissesTowardFrameEdge();
|
|
if (inwardConnectedExit)
|
|
content.chromeOnlyExit = true;
|
|
if (win.connectedFrameMode) {
|
|
win.startExit();
|
|
NotificationService.dismissNotification(notificationData);
|
|
} else {
|
|
NotificationService.dismissNotification(notificationData);
|
|
win.forceExit();
|
|
}
|
|
}
|
|
}
|
|
|
|
transform: [
|
|
Translate {
|
|
id: swipeTx
|
|
x: content.swipeOffset
|
|
y: 0
|
|
},
|
|
Translate {
|
|
id: tx
|
|
x: {
|
|
if (isCenterPosition)
|
|
return 0;
|
|
const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom;
|
|
return isLeft ? -entryTravel : entryTravel;
|
|
}
|
|
y: isTopCenter ? -entryTravel : isBottomCenter ? entryTravel : 0
|
|
onXChanged: {
|
|
if (win.connectedFrameMode)
|
|
win.popupChromeGeometryChanged();
|
|
}
|
|
onYChanged: {
|
|
if (win.connectedFrameMode)
|
|
win.popupChromeGeometryChanged();
|
|
}
|
|
}
|
|
]
|
|
}
|
|
|
|
NumberAnimation {
|
|
id: enterX
|
|
|
|
target: tx
|
|
property: isCenterPosition ? "y" : "x"
|
|
from: {
|
|
if (isTopCenter)
|
|
return -entryTravel;
|
|
if (isBottomCenter)
|
|
return entryTravel;
|
|
const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom;
|
|
return isLeft ? -entryTravel : entryTravel;
|
|
}
|
|
to: 0
|
|
duration: Theme.notificationEnterDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.variantPopoutEnterCurve
|
|
onStopped: {
|
|
if (!win.exiting && !win._isDestroying) {
|
|
if (isCenterPosition) {
|
|
if (Math.abs(tx.y) < 0.5)
|
|
win.entered();
|
|
} else {
|
|
if (Math.abs(tx.x) < 0.5)
|
|
win.entered();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ParallelAnimation {
|
|
id: exitAnim
|
|
|
|
onStopped: finalizeExit("animStopped")
|
|
|
|
PropertyAnimation {
|
|
target: tx
|
|
property: isCenterPosition ? "y" : "x"
|
|
from: 0
|
|
to: {
|
|
if (isTopCenter)
|
|
return -exitTravel;
|
|
if (isBottomCenter)
|
|
return exitTravel;
|
|
const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom;
|
|
return isLeft ? -exitTravel : exitTravel;
|
|
}
|
|
duration: Theme.notificationExitDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.variantPopoutExitCurve
|
|
}
|
|
|
|
NumberAnimation {
|
|
target: content
|
|
property: "opacity"
|
|
to: Theme.isDirectionalEffect ? 1 : 0
|
|
duration: Theme.notificationExitDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.variantPopoutExitCurve
|
|
}
|
|
|
|
NumberAnimation {
|
|
target: content
|
|
property: "scale"
|
|
to: Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed
|
|
duration: Theme.notificationExitDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.variantPopoutExitCurve
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
id: wrapperConn
|
|
|
|
function onPopupChanged() {
|
|
if (!win.notificationData || win._isDestroying)
|
|
return;
|
|
if (!win.notificationData.popup && !win.exiting)
|
|
startExit();
|
|
}
|
|
|
|
target: win.notificationData || null
|
|
ignoreUnknownSignals: true
|
|
enabled: !win._isDestroying
|
|
}
|
|
|
|
Connections {
|
|
id: notificationConn
|
|
|
|
function onDropped() {
|
|
if (!win._isDestroying && !win.exiting)
|
|
forceExit();
|
|
}
|
|
|
|
target: (win.notificationData && win.notificationData.notification && win.notificationData.notification.Retainable) || null
|
|
ignoreUnknownSignals: true
|
|
enabled: !win._isDestroying
|
|
}
|
|
|
|
Timer {
|
|
id: enterDelay
|
|
|
|
interval: 160
|
|
repeat: false
|
|
onTriggered: {
|
|
if (notificationData && notificationData.timer && !contextMenuActive && !exiting && !_isDestroying)
|
|
notificationData.timer.start();
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: popupContextMenuLoader.item
|
|
ignoreUnknownSignals: true
|
|
|
|
function onVisibleChanged() {
|
|
if (!notificationData?.timer || exiting || _isDestroying)
|
|
return;
|
|
if (win.contextMenuActive) {
|
|
notificationData.timer.stop();
|
|
} else if (!cardHoverHandler.hovered && notificationData.popup) {
|
|
notificationData.timer.restart();
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: exitWatchdog
|
|
|
|
interval: 600
|
|
repeat: false
|
|
onTriggered: finalizeExit("watchdog")
|
|
}
|
|
|
|
Behavior on screenY {
|
|
id: screenYAnim
|
|
|
|
enabled: !exiting && !_isDestroying
|
|
|
|
NumberAnimation {
|
|
duration: Theme.shortDuration
|
|
easing.type: Easing.BezierSpline
|
|
easing.bezierCurve: Theme.expressiveCurves.standardDecel
|
|
}
|
|
}
|
|
|
|
Loader {
|
|
id: popupContextMenuLoader
|
|
active: false
|
|
|
|
sourceComponent: NotificationContextMenu {
|
|
transientSurfaceTracker: transientSurfaces
|
|
appName: notificationData?.appName ?? ""
|
|
desktopEntry: notificationData?.desktopEntry ?? ""
|
|
onMuted: {
|
|
if (notificationData && !win.exiting)
|
|
NotificationService.dismissNotification(notificationData);
|
|
}
|
|
onDismissRequested: {
|
|
if (notificationData && !win.exiting)
|
|
NotificationService.dismissNotification(notificationData);
|
|
}
|
|
}
|
|
}
|
|
}
|