From 862169aba09cdbd70d41219702092adcd52910b7 Mon Sep 17 00:00:00 2001 From: Damocles Date: Fri, 17 Apr 2026 10:34:11 +0200 Subject: [PATCH 1/2] notifcenter: collapsible app groups, hover-only group dismiss, full opacity on notif hover --- modules/NotifCard.qml | 2 +- modules/NotifCenter.qml | 130 +++++++++++++++++++++++++++++++++------- 2 files changed, 110 insertions(+), 22 deletions(-) diff --git a/modules/NotifCard.qml b/modules/NotifCard.qml index bd19ee0..fa4b8da 100644 --- a/modules/NotifCard.qml +++ b/modules/NotifCard.qml @@ -28,7 +28,7 @@ Item { Rectangle { anchors.fill: parent color: _hover.hovered ? M.Theme.base02 : M.Theme.base01 - opacity: Math.max(M.Theme.barOpacity, 0.9) + opacity: _hover.hovered ? 1.0 : Math.max(M.Theme.barOpacity, 0.9) radius: M.Theme.radius Behavior on color { diff --git a/modules/NotifCenter.qml b/modules/NotifCenter.qml index ee07ebf..8b54608 100644 --- a/modules/NotifCenter.qml +++ b/modules/NotifCenter.qml @@ -67,6 +67,18 @@ M.HoverPanel { property var _pendingDismissIds: [] + // Collapsed groups set — reassign to trigger reactivity + property var _collapsedGroups: ({}) + + function _toggleCollapse(appName) { + const next = Object.assign({}, _collapsedGroups); + if (next[appName]) + delete next[appName]; + else + next[appName] = true; + _collapsedGroups = next; + } + // Group notifications by appName, sorted by max urgency desc then most recent time desc readonly property var _groups: { const map = {}; @@ -93,21 +105,26 @@ M.HoverPanel { }); } - // Flat model: group header followed by its notifications + // Flat model: group header followed by its notifications (omitted when collapsed) readonly property var _flatModel: { const arr = []; for (const g of _groups) { + const collapsed = !!_collapsedGroups[g.appName]; arr.push({ type: "header", appName: g.appName, appIcon: g.appIcon, - count: g.notifs.length + count: g.notifs.length, + collapsed: collapsed, + summaries: g.notifs.map(n => n.summary || "").filter(Boolean).join(" · ") }); - for (const n of g.notifs) - arr.push({ - type: "notif", - data: n - }); + if (!collapsed) { + for (const n of g.notifs) + arr.push({ + type: "notif", + data: n + }); + } } return arr; } @@ -218,11 +235,25 @@ M.HoverPanel { readonly property var _notif: _type === "notif" ? modelData.data : null width: menuWindow.contentWidth - height: _targetHeight * _heightScale + height: _displayTargetHeight * _heightScale clip: true opacity: 0 - readonly property real _targetHeight: _type === "header" ? 28 : _notifCard.implicitHeight + readonly property real _targetHeight: { + if (_type === "header") + return modelData.collapsed ? (28 + M.Theme.fontSize + 4) : 28; + return _notifCard.implicitHeight; + } + + // Animated version of _targetHeight — smoothly transitions header height on collapse + property real _displayTargetHeight: _targetHeight + Behavior on _displayTargetHeight { + NumberAnimation { + duration: 150 + easing.type: Easing.OutCubic + } + } + property real _heightScale: 1 property bool _skipDismiss: false @@ -257,11 +288,29 @@ M.HoverPanel { visible: notifDelegate._type === "header" anchors.fill: parent + HoverHandler { + id: _headerHover + } + + // Tap target for collapse — covers header row only, excludes dismiss button + Item { + anchors.left: parent.left + anchors.right: _groupDismissBtn.left + anchors.top: parent.top + height: 28 + + TapHandler { + cursorShape: Qt.PointingHandCursor + onTapped: menuWindow._toggleCollapse(notifDelegate.modelData.appName) + } + } + Image { id: _headerIcon anchors.left: parent.left anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter + anchors.top: parent.top + anchors.topMargin: (28 - height) / 2 width: M.Theme.fontSize + 2 height: M.Theme.fontSize + 2 source: { @@ -278,12 +327,29 @@ M.HoverPanel { asynchronous: true } + // Collapse chevron + Text { + id: _chevron + anchors.right: _groupDismissBtn.left + anchors.rightMargin: 8 + anchors.top: parent.top + height: 28 + verticalAlignment: Text.AlignVCenter + text: notifDelegate._type === "header" && notifDelegate.modelData.collapsed ? "\u25B8" : "\u25BE" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + } + + // App name Text { anchors.left: _headerIcon.visible ? _headerIcon.right : parent.left anchors.leftMargin: _headerIcon.visible ? 6 : 10 - anchors.right: _groupDismissBtn.left - anchors.rightMargin: 6 - anchors.verticalCenter: parent.verticalCenter + anchors.right: _chevron.left + anchors.rightMargin: 4 + anchors.top: parent.top + height: 28 + verticalAlignment: Text.AlignVCenter text: notifDelegate._type === "header" ? (notifDelegate.modelData.appName || "Unknown") : "" color: M.Theme.base05 font.pixelSize: M.Theme.fontSize - 1 @@ -292,28 +358,50 @@ M.HoverPanel { elide: Text.ElideRight } + // Dismiss button — opacity-hidden when header not hovered Text { id: _groupDismissBtn anchors.right: parent.right anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter + anchors.top: parent.top + height: 28 + verticalAlignment: Text.AlignVCenter text: "\uF1F8" - color: _groupDismissArea.containsMouse ? M.Theme.base08 : M.Theme.base04 + color: _groupDismissHover.hovered ? M.Theme.base08 : M.Theme.base04 font.pixelSize: M.Theme.fontSize - 1 font.family: M.Theme.iconFontFamily + opacity: _headerHover.hovered ? 1 : 0 - MouseArea { - id: _groupDismissArea - anchors.fill: parent - anchors.margins: -4 - hoverEnabled: true + HoverHandler { + id: _groupDismissHover + } + + TapHandler { cursorShape: Qt.PointingHandCursor - onClicked: { + onTapped: { if (notifDelegate._type === "header") menuWindow._cascadeGroupDismiss(notifDelegate.modelData.appName); } } } + + // Collapsed preview: notification summaries on a subtitle row + Text { + visible: notifDelegate._type === "header" && notifDelegate.modelData.collapsed + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.top: parent.top + anchors.topMargin: 28 + height: M.Theme.fontSize + 4 + verticalAlignment: Text.AlignVCenter + text: notifDelegate._type === "header" ? (notifDelegate.modelData.summaries ?? "") : "" + elide: Text.ElideRight + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + color: M.Theme.base04 + } } // ---- Individual notification ---- From 7cd6716eb8d98a93c95939b1b55ae38fac65c215 Mon Sep 17 00:00:00 2001 From: Damocles Date: Fri, 17 Apr 2026 10:34:34 +0200 Subject: [PATCH 2/2] feat(overview-backdrop): softer wave, breathing pulse, random glitches --- modules/OverviewBackdrop.qml | 68 +++++++++++++++++++++++++++++++++++- modules/hex_wave.frag | 47 ++++++++++++++++++------- 2 files changed, 101 insertions(+), 14 deletions(-) diff --git a/modules/OverviewBackdrop.qml b/modules/OverviewBackdrop.qml index ab17be2..6abb7e8 100644 --- a/modules/OverviewBackdrop.qml +++ b/modules/OverviewBackdrop.qml @@ -32,6 +32,9 @@ PanelWindow { property real uSize: 50.0 property real uWavePhase: -200 + property real uBreath: 0 + property real uGlitch: 0 + property real uGlitchSeed: 0.0 property vector4d uResolution: Qt.vector4d(width, height, 0, 0) property color uC0: M.Theme.base0C property color uC1: M.Theme.base0E @@ -40,8 +43,10 @@ PanelWindow { Connections { target: M.NiriIpc function onOverviewOpenChanged() { - if (!M.NiriIpc.overviewOpen) + if (!M.NiriIpc.overviewOpen) { fx.uWavePhase = -200; + fx.uBreath = 0; + } } } @@ -59,5 +64,66 @@ PanelWindow { duration: 8000 } } + + // Breathing pulse while overview is open + SequentialAnimation on uBreath { + loops: Animation.Infinite + running: M.NiriIpc.overviewOpen && !M.Theme.reducedMotion + NumberAnimation { + from: 0 + to: 1 + duration: 2500 + easing.type: Easing.InOutSine + } + NumberAnimation { + from: 1 + to: 0 + duration: 2500 + easing.type: Easing.InOutSine + } + } + + // Random subtle glitches — fire every 12–37s, total ~250ms each + Timer { + interval: 20000 + repeat: true + running: !M.Theme.reducedMotion + onTriggered: { + interval = 12000 + Math.floor(Math.random() * 25000); + fx.uGlitchSeed = Math.random() * 1000.0; + _glitchAnim.start(); + } + } + + SequentialAnimation { + id: _glitchAnim + NumberAnimation { + target: fx + property: "uGlitch" + to: 0.7 + duration: 50 + easing.type: Easing.OutQuad + } + NumberAnimation { + target: fx + property: "uGlitch" + to: 0.15 + duration: 50 + } + NumberAnimation { + target: fx + property: "uGlitch" + to: 0.85 + duration: 60 + easing.type: Easing.OutQuad + } + NumberAnimation { + target: fx + property: "uGlitch" + to: 0 + duration: 100 + easing.type: Easing.InQuad + } + } } } diff --git a/modules/hex_wave.frag b/modules/hex_wave.frag index 6f204df..4a87666 100644 --- a/modules/hex_wave.frag +++ b/modules/hex_wave.frag @@ -8,6 +8,9 @@ layout(std140, binding = 0) uniform buf { float qt_Opacity; float uSize; float uWavePhase; + float uBreath; + float uGlitch; + float uGlitchSeed; vec4 uResolution; vec4 uC0; vec4 uC1; @@ -29,9 +32,24 @@ vec3 themeGradient(float t) { : mix(uC1.rgb, uC2.rgb, (t - 0.5) * 2.0); } +// Cheap hash for glitch bands +float hash(float n) { + return fract(sin(n) * 43758.5453123); +} + void main() { vec2 res = uResolution.xy; + + // Glitch: shift some horizontal bands slightly vec2 frag = qt_TexCoord0 * res; + if (uGlitch > 0.0) { + float band = floor(frag.y / 7.0); + float h = hash(band * 127.1 + uGlitchSeed * 311.7); + if (h > 0.72) { + float shift = (hash(band * 93.9 + uGlitchSeed) - 0.5) * 14.0 * uGlitch; + frag.x = clamp(frag.x + shift, 0.0, res.x); + } + } float dx = uSize * 1.5; float dy = uSize * 1.7320508; @@ -41,12 +59,12 @@ void main() { float row = round((frag.y - yoff) / dy); vec2 center = vec2(col * dx, row * dy + yoff); - // Wave factor + // Wave — wide gaussian so it reads more as a broad pulse than a sharp sweep float dist = center.x - uWavePhase; - float wf = exp(-dist * dist / 9000.0); + float wf = exp(-dist * dist / 40000.0); float baseR = uSize * 0.48; - float inradius = baseR * (1.0 + 0.35 * wf); + float inradius = baseR * (1.0 + 0.15 * wf); vec2 p = frag - center; float d = sdHexagon(p.yx, inradius); // swap for flat-top @@ -62,28 +80,31 @@ void main() { ? mix(uC0.rgb, uC1.rgb, fx * 2.0) : mix(uC1.rgb, uC2.rgb, (fx - 0.5) * 2.0); - // Alpha from distance to center + // Alpha from distance to center (vignette) float fy = clamp(center.y / res.y, 0.0, 1.0); float dc = length(vec2(fx - 0.5, fy - 0.5)); float a = 0.03 + dc * 0.06; - // Wave brighten - rgb = min(rgb + vec3(0.3 * wf), vec3(1.0)); - a += 0.12 * wf; + // Breathing pulse (when overview open) + a += 0.025 * uBreath; + rgb = min(rgb + vec3(0.04 * uBreath), vec3(1.0)); - // Rainbow shimmer on hex edges when wave passes + // Wave brighten (softer than before) + rgb = min(rgb + vec3(0.15 * wf), vec3(1.0)); + a += 0.07 * wf; + + // Shimmer on hex edges as wave passes float edgeWidth = 5.0; - float edgeFactor = smoothstep(-edgeWidth, 0.0, d); // 0 at interior, 1 at edge + float edgeFactor = smoothstep(-edgeWidth, 0.0, d); if (wf > 0.01 && edgeFactor > 0.0) { - // Vary shimmer color across theme gradient using angle + position float angle = atan(p.y, p.x); float rawT = fract((angle + 3.14159) / 6.28318 + center.x * 0.003 + center.y * 0.005); - float t = abs(rawT * 2.0 - 1.0); // triangle wave → uC0→uC1→uC2→uC1→uC0, no hard jump + float t = abs(rawT * 2.0 - 1.0); vec3 shimmerColor = themeGradient(t); float shimmer = edgeFactor * wf; - rgb = mix(rgb, shimmerColor, shimmer * 0.8); - a = mix(a, 0.5, shimmer * 0.6); + rgb = mix(rgb, shimmerColor, shimmer * 0.5); + a = mix(a, 0.5, shimmer * 0.35); } // Anti-alias outer edge