Compare commits

..

2 commits

4 changed files with 211 additions and 36 deletions

View file

@ -28,7 +28,7 @@ Item {
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
color: _hover.hovered ? M.Theme.base02 : M.Theme.base01 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 radius: M.Theme.radius
Behavior on color { Behavior on color {

View file

@ -67,6 +67,18 @@ M.HoverPanel {
property var _pendingDismissIds: [] 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 // Group notifications by appName, sorted by max urgency desc then most recent time desc
readonly property var _groups: { readonly property var _groups: {
const map = {}; const map = {};
@ -93,22 +105,27 @@ 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: { readonly property var _flatModel: {
const arr = []; const arr = [];
for (const g of _groups) { for (const g of _groups) {
const collapsed = !!_collapsedGroups[g.appName];
arr.push({ arr.push({
type: "header", type: "header",
appName: g.appName, appName: g.appName,
appIcon: g.appIcon, appIcon: g.appIcon,
count: g.notifs.length count: g.notifs.length,
collapsed: collapsed,
summaries: g.notifs.map(n => n.summary || "").filter(Boolean).join(" · ")
}); });
if (!collapsed) {
for (const n of g.notifs) for (const n of g.notifs)
arr.push({ arr.push({
type: "notif", type: "notif",
data: n data: n
}); });
} }
}
return arr; return arr;
} }
@ -218,11 +235,25 @@ M.HoverPanel {
readonly property var _notif: _type === "notif" ? modelData.data : null readonly property var _notif: _type === "notif" ? modelData.data : null
width: menuWindow.contentWidth width: menuWindow.contentWidth
height: _targetHeight * _heightScale height: _displayTargetHeight * _heightScale
clip: true clip: true
opacity: 0 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 real _heightScale: 1
property bool _skipDismiss: false property bool _skipDismiss: false
@ -257,11 +288,29 @@ M.HoverPanel {
visible: notifDelegate._type === "header" visible: notifDelegate._type === "header"
anchors.fill: parent 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 { Image {
id: _headerIcon id: _headerIcon
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: 10 anchors.leftMargin: 10
anchors.verticalCenter: parent.verticalCenter anchors.top: parent.top
anchors.topMargin: (28 - height) / 2
width: M.Theme.fontSize + 2 width: M.Theme.fontSize + 2
height: M.Theme.fontSize + 2 height: M.Theme.fontSize + 2
source: { source: {
@ -278,12 +327,29 @@ M.HoverPanel {
asynchronous: true 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 { Text {
anchors.left: _headerIcon.visible ? _headerIcon.right : parent.left anchors.left: _headerIcon.visible ? _headerIcon.right : parent.left
anchors.leftMargin: _headerIcon.visible ? 6 : 10 anchors.leftMargin: _headerIcon.visible ? 6 : 10
anchors.right: _groupDismissBtn.left anchors.right: _chevron.left
anchors.rightMargin: 6 anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter anchors.top: parent.top
height: 28
verticalAlignment: Text.AlignVCenter
text: notifDelegate._type === "header" ? (notifDelegate.modelData.appName || "Unknown") : "" text: notifDelegate._type === "header" ? (notifDelegate.modelData.appName || "Unknown") : ""
color: M.Theme.base05 color: M.Theme.base05
font.pixelSize: M.Theme.fontSize - 1 font.pixelSize: M.Theme.fontSize - 1
@ -292,28 +358,50 @@ M.HoverPanel {
elide: Text.ElideRight elide: Text.ElideRight
} }
// Dismiss button opacity-hidden when header not hovered
Text { Text {
id: _groupDismissBtn id: _groupDismissBtn
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: 10 anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter anchors.top: parent.top
height: 28
verticalAlignment: Text.AlignVCenter
text: "\uF1F8" 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.pixelSize: M.Theme.fontSize - 1
font.family: M.Theme.iconFontFamily font.family: M.Theme.iconFontFamily
opacity: _headerHover.hovered ? 1 : 0
MouseArea { HoverHandler {
id: _groupDismissArea id: _groupDismissHover
anchors.fill: parent }
anchors.margins: -4
hoverEnabled: true TapHandler {
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onTapped: {
if (notifDelegate._type === "header") if (notifDelegate._type === "header")
menuWindow._cascadeGroupDismiss(notifDelegate.modelData.appName); 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 ---- // ---- Individual notification ----

View file

@ -32,6 +32,9 @@ PanelWindow {
property real uSize: 50.0 property real uSize: 50.0
property real uWavePhase: -200 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 vector4d uResolution: Qt.vector4d(width, height, 0, 0)
property color uC0: M.Theme.base0C property color uC0: M.Theme.base0C
property color uC1: M.Theme.base0E property color uC1: M.Theme.base0E
@ -40,8 +43,10 @@ PanelWindow {
Connections { Connections {
target: M.NiriIpc target: M.NiriIpc
function onOverviewOpenChanged() { function onOverviewOpenChanged() {
if (!M.NiriIpc.overviewOpen) if (!M.NiriIpc.overviewOpen) {
fx.uWavePhase = -200; fx.uWavePhase = -200;
fx.uBreath = 0;
}
} }
} }
@ -59,5 +64,66 @@ PanelWindow {
duration: 8000 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 1237s, 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
}
}
} }
} }

View file

@ -8,6 +8,9 @@ layout(std140, binding = 0) uniform buf {
float qt_Opacity; float qt_Opacity;
float uSize; float uSize;
float uWavePhase; float uWavePhase;
float uBreath;
float uGlitch;
float uGlitchSeed;
vec4 uResolution; vec4 uResolution;
vec4 uC0; vec4 uC0;
vec4 uC1; vec4 uC1;
@ -29,9 +32,24 @@ vec3 themeGradient(float t) {
: mix(uC1.rgb, uC2.rgb, (t - 0.5) * 2.0); : 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() { void main() {
vec2 res = uResolution.xy; vec2 res = uResolution.xy;
// Glitch: shift some horizontal bands slightly
vec2 frag = qt_TexCoord0 * res; 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 dx = uSize * 1.5;
float dy = uSize * 1.7320508; float dy = uSize * 1.7320508;
@ -41,12 +59,12 @@ void main() {
float row = round((frag.y - yoff) / dy); float row = round((frag.y - yoff) / dy);
vec2 center = vec2(col * dx, row * dy + yoff); 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 dist = center.x - uWavePhase;
float wf = exp(-dist * dist / 9000.0); float wf = exp(-dist * dist / 40000.0);
float baseR = uSize * 0.48; 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; vec2 p = frag - center;
float d = sdHexagon(p.yx, inradius); // swap for flat-top 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(uC0.rgb, uC1.rgb, fx * 2.0)
: mix(uC1.rgb, uC2.rgb, (fx - 0.5) * 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 fy = clamp(center.y / res.y, 0.0, 1.0);
float dc = length(vec2(fx - 0.5, fy - 0.5)); float dc = length(vec2(fx - 0.5, fy - 0.5));
float a = 0.03 + dc * 0.06; float a = 0.03 + dc * 0.06;
// Wave brighten // Breathing pulse (when overview open)
rgb = min(rgb + vec3(0.3 * wf), vec3(1.0)); a += 0.025 * uBreath;
a += 0.12 * wf; 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 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) { if (wf > 0.01 && edgeFactor > 0.0) {
// Vary shimmer color across theme gradient using angle + position
float angle = atan(p.y, p.x); float angle = atan(p.y, p.x);
float rawT = fract((angle + 3.14159) / 6.28318 + center.x * 0.003 + center.y * 0.005); 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); vec3 shimmerColor = themeGradient(t);
float shimmer = edgeFactor * wf; float shimmer = edgeFactor * wf;
rgb = mix(rgb, shimmerColor, shimmer * 0.8); rgb = mix(rgb, shimmerColor, shimmer * 0.5);
a = mix(a, 0.5, shimmer * 0.6); a = mix(a, 0.5, shimmer * 0.35);
} }
// Anti-alias outer edge // Anti-alias outer edge