6 Commits
Author SHA1 Message Date
zach f8101b2807 feat: sql database for notifications 2026-11-01 12:43:21 +01:00
zach 8271e5d013 feat: testing new kinetic scroller for notifications in sidebar 2026-11-01 01:57:13 +01:00
zach 90184bff58 Merge pull request 'feat: video wallpapers' (#163) from feat/video-wallpaper into main
Reviewed-on: #163
2026-10-29 19:55:05 +01:00
zach abdde2450d fix: don't use lambda expression
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 7s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Failing after 1m27s
Rust / fmt (pull_request) Successful in 31s
Rust / clippy (pull_request) Successful in 58s
2026-10-29 19:50:02 +01:00
zach fd05b3d4c4 Merge branch 'main' into feat/video-wallpaper
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Successful in 13s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Failing after 37s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Failing after 1m28s
Rust / fmt (pull_request) Successful in 39s
Rust / clippy (pull_request) Successful in 1m5s
2026-10-29 19:03:10 +01:00
zach 9d8bde1413 fix: update for hyprland 0.57 2026-10-29 11:59:50 +01:00
15 changed files with 2379 additions and 344 deletions
+23
View File
@@ -0,0 +1,23 @@
import QtQuick
import qs.Services
Flickable {
id: root
interactive: !ShellState.forActive().isDrawing
rebound: Transition {
Anim {
properties: "x,y"
type: Anim.FastEffects
}
}
readonly property KineticWheelScroller _scroller: KineticWheelScroller {
id: wheelScroller
anchors.fill: parent
parent: root
z: 12
flickable: root
}
}
+654
View File
@@ -0,0 +1,654 @@
import QtQuick
// Taken from hyprfm: https://github.com/soyeb-jim285/hyprfm/blob/main/src/qml/views/KineticWheelScroller.qml
// Adapted to fit this project where needed.
// Deliberately not a MouseArea: a MouseArea claims the arrow cursor for its
// whole area, which silently killed every cursorShape in the views this
// overlays (column resize grips, pointer hands). A WheelHandler on a plain
// Item takes wheel events without touching the cursor.
Item {
id: root
required property var flickable
property real wheelStep: 56
property real mouseWheelMultiplier: 1.0
property real touchpadMultiplier: 1.35
property real minVelocity: 480
property real maxVelocity: 5200
property real kineticGain: 1.35
property real smoothing: 0.72
property real edgeEpsilon: 1.0
// Browser-style overscroll: larger visible rubber-band, softer
// resistance, and a springy settle on release.
property real overshootLimit: 140
property real overshootResistance: 0.38
property real maxOvershootInput: 520
// Bounce magnitude per unit of impact velocity. Keep this deliberately
// small: slow edge hits should settle, not fire a visibly faster bounce.
property real kineticBounceGain: 0.018
property real maxKineticBounceInput: 90
// Minimum impact velocity that actually produces a visible bounce.
// Very slow landings skip bounce entirely — matches what browsers do
// when a fling decays to near-zero before reaching the edge.
property real minBounceVelocity: 260
property real velocity: 0
property double lastWheelTimestamp: 0
property bool kineticCandidate: false
property real overshootInput: 0
property real overshootReleaseVelocity: 0
property real pendingKineticBounceVelocity: 0
// Set by runKineticGlide when the glide will clamp against an edge.
// It is the glide velocity at the instant of impact, which drives the
// elastic bounce amount and duration — browser-style: slow impact
// makes a small gentle bounce; fast impact makes a big springy one.
property real pendingImpactVelocity: 0
// Browser-style exponential-feeling glide replaces the linear
// flickDeceleration decay. Tail uses OutQuart — soft landing.
property real kineticTau: 0.42
property real mouseGlideTime: 0.09 // decay time constant (s); higher = longer coast
property real mouseAccelWindow: 140 // ms between notches that counts as "continuous"
property real mouseAccelStep: 0.15 // extra impulse multiplier per consecutive notch
property real mouseMaxAccel: 2.5
property real mouseSnap: 0.2 // fraction of each notch applied instantly (0 = fully glided, 1 = no smoothing)
property real mouseVelocity: 0
property int mouseDir: 0
property int mouseStreak: 0
property double mouseLastTime: 0
// Escape hatch if auto-detection misjudges a device
property bool forceMouseWheel: false
readonly property real visualOvershoot: overshootInput < 0 ? rubberDistance(-overshootInput) : -rubberDistance(overshootInput)
signal scrollStarted
WheelHandler {
id: wheelHandler
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
// `blocking` decides whether the event continues to the Flickable
// underneath; handleWheel() flips it per event (the old MouseArea
// code toggled wheel.accepted for the same purpose).
onWheel: wheel => root.handleWheel(wheel)
}
function isMouseWheel(wheel) {
if (forceMouseWheel)
return true;
var ay = wheel.angleDelta.y;
if (ay === 0)
return false;
return Math.abs(ay) % 120 === 0;
}
function minimumContentY() {
if (!flickable)
return 0;
return flickable.originY;
}
function maximumContentY() {
if (!flickable)
return 0;
return Math.max(minimumContentY(), minimumContentY() + flickable.contentHeight - flickable.height);
}
function boundedContentY(value) {
return Math.max(minimumContentY(), Math.min(maximumContentY(), value));
}
function rubberDistance(distance) {
if (distance <= 0)
return 0;
var scaled = (distance * overshootResistance) / overshootLimit;
return overshootLimit * (1 - 1 / (1 + scaled));
}
function setOvershootInput(value) {
overshootInput = Math.max(-maxOvershootInput, Math.min(maxOvershootInput, value));
}
function hasOvershoot() {
return Math.abs(overshootInput) > edgeEpsilon;
}
function absorbOvershoot(delta) {
if (!hasOvershoot())
return delta;
var sameDirection = (overshootInput < 0 && delta < 0) || (overshootInput > 0 && delta > 0);
if (sameDirection) {
setOvershootInput(overshootInput + delta);
return 0;
}
var combined = overshootInput + delta;
if ((overshootInput < 0 && combined < 0) || (overshootInput > 0 && combined > 0)) {
setOvershootInput(combined);
return 0;
}
setOvershootInput(0);
return combined;
}
function deltaFor(wheel) {
var rawDelta;
if (isMouseWheel(wheel))
rawDelta = (wheel.angleDelta.y / 120.0) * wheelStep * mouseWheelMultiplier;
else
rawDelta = wheel.pixelDelta.y !== 0 ? wheel.pixelDelta.y * touchpadMultiplier : (wheel.angleDelta.y / 120.0) * wheelStep * mouseWheelMultiplier;
return wheel.inverted ? rawDelta : -rawDelta;
}
function shouldUseKinetic(wheel, gap) {
return wheel.pixelDelta.y !== 0 || (wheel.phase !== Qt.NoScrollPhase && wheel.angleDelta.y === 0);
}
function scrollBy(delta) {
if (!flickable || Math.abs(delta) < 0.01)
return;
kineticBounceSequence.stop();
bounceBackAnimation.stop();
var remainingDelta = absorbOvershoot(delta);
if (Math.abs(remainingDelta) < 0.01)
return;
var minY = minimumContentY();
var maxY = maximumContentY();
var next = flickable.contentY + remainingDelta;
if (next < minY) {
flickable.contentY = minY;
setOvershootInput(overshootInput + next - minY);
return;
}
if (next > maxY) {
flickable.contentY = maxY;
setOvershootInput(overshootInput + next - maxY);
return;
}
flickable.contentY = next;
}
function remainingDistanceFor(velocityValue) {
if (!flickable)
return 0;
if (velocityValue > 0)
return Math.max(0, maximumContentY() - flickable.contentY);
if (velocityValue < 0)
return Math.max(0, flickable.contentY - minimumContentY());
return 0;
}
function clampedContentVelocity(velocityValue) {
if (!flickable || velocityValue === 0)
return 0;
var remaining = remainingDistanceFor(velocityValue);
if (remaining <= edgeEpsilon)
return 0;
var maxAllowed = Math.sqrt(2 * flickable.flickDeceleration * remaining);
var magnitude = Math.min(Math.abs(velocityValue), maxAllowed);
return velocityValue < 0 ? -magnitude : magnitude;
}
function resetState() {
velocity = 0;
lastWheelTimestamp = 0;
kineticCandidate = false;
pendingKineticBounceVelocity = 0;
pendingImpactVelocity = 0;
overshootReleaseVelocity = 0;
finishTimer.stop();
kineticAnim.stop();
kineticBounceSequence.stop();
bounceBackAnimation.stop();
stopMouseGlide();
}
function bounceBack() {
if (!hasOvershoot()) {
setOvershootInput(0);
overshootReleaseVelocity = 0;
return;
}
var mag = Math.min(1.0, Math.abs(overshootInput) / maxOvershootInput);
var speed = Math.abs(overshootReleaseVelocity);
var duration = speed >= minBounceVelocity ? Math.round(Math.max(280, Math.min(620, Math.abs(overshootInput) / speed * 1600))) : Math.round(380 + mag * 260);
kineticBounceSequence.stop();
bounceBackAnimation.stop();
bounceBackAnimation.duration = duration;
bounceBackAnimation.from = overshootInput;
bounceBackAnimation.to = 0;
overshootReleaseVelocity = 0;
bounceBackAnimation.start();
}
function triggerKineticBounce(contentVelocity) {
if (!flickable || contentVelocity === 0)
return;
var atTop = flickable.contentY <= minimumContentY() + edgeEpsilon;
var atBottom = flickable.contentY >= maximumContentY() - edgeEpsilon;
if ((contentVelocity < 0 && !atTop) || (contentVelocity > 0 && !atBottom))
return;
var speed = Math.abs(contentVelocity);
var effectiveVelocity = contentVelocity;
if (speed < minBounceVelocity)
effectiveVelocity = (contentVelocity < 0 ? -1 : 1) * Math.max(90, speed * 0.6);
var target = Math.max(-maxKineticBounceInput, Math.min(maxKineticBounceInput, effectiveVelocity * kineticBounceGain));
if (Math.abs(target) < 2.5)
target = contentVelocity < 0 ? -2.5 : 2.5;
var mag = Math.min(1.0, Math.abs(target) / maxKineticBounceInput);
var inDur = Math.round(150 + mag * 120);
var outDur = Math.round(320 + mag * 300);
kineticBounceSequence.stop();
bounceBackAnimation.stop();
overshootReleaseVelocity = 0;
overshootInput = 0;
kineticBounceIn.from = 0;
kineticBounceIn.to = target;
kineticBounceIn.duration = inDur;
kineticBounceOut.from = target;
kineticBounceOut.to = 0;
kineticBounceOut.duration = outDur;
kineticBounceSequence.start();
}
function stopAndSettle() {
kineticAnim.stop();
if (flickable && flickable.flicking)
flickable.cancelFlick();
resetState();
bounceBack();
}
function runKineticGlide(contentVelocity) {
// contentVelocity > 0 means the content should scroll downward
// (contentY increases), matching deltaFor()'s sign convention.
if (!flickable || contentVelocity === 0) {
pendingImpactVelocity = 0;
return false;
}
var current = flickable.contentY;
var unclampedTarget = current + contentVelocity * kineticTau;
var target = boundedContentY(unclampedTarget);
var distUnclamped = Math.abs(unclampedTarget - current);
var distActual = Math.abs(target - current);
if (distActual < 4) {
pendingImpactVelocity = 0;
return false;
}
// If the glide is clipped by an edge, compute the velocity at the
// moment of impact using the OutQuart velocity profile. That is
// the velocity the bounce should feed on — gentle impacts get
// gentle bounces, hard impacts get big springy ones.
var hitsEdge = distActual + 0.5 < distUnclamped;
if (hitsEdge && distUnclamped > 0) {
var f = distActual / distUnclamped; // fraction covered
// OutQuart: y = 1 - (1-x)^4, so velocity fraction at distance
// fraction f equals (1-f)^(3/4) (times the constant peak).
var velFraction = Math.pow(Math.max(0, 1 - f), 0.75);
pendingImpactVelocity = contentVelocity * velFraction;
} else {
pendingImpactVelocity = 0;
}
kineticAnim.stop();
kineticAnim.from = current;
kineticAnim.to = target;
// Duration from distance and velocity; clamped so short flicks
// stay snappy and long flicks don't run forever.
kineticAnim.duration = Math.round(Math.min(1400, Math.max(180, distActual / Math.abs(contentVelocity) * 1800)));
kineticAnim.start();
return true;
}
function atEdgeFor(contentVelocity) {
if (!flickable)
return false;
if (contentVelocity > 0)
return flickable.contentY >= maximumContentY() - edgeEpsilon;
if (contentVelocity < 0)
return flickable.contentY <= minimumContentY() + edgeEpsilon;
return false;
}
function finishScroll() {
if (!flickable)
return;
finishTimer.stop();
if (hasOvershoot()) {
velocity = 0;
lastWheelTimestamp = 0;
kineticCandidate = false;
pendingKineticBounceVelocity = 0;
pendingImpactVelocity = 0;
kineticAnim.stop();
bounceBack();
return;
}
var rawContentVelocity = Math.max(-maxVelocity, Math.min(maxVelocity, velocity * kineticGain));
var contentVelocity = clampedContentVelocity(rawContentVelocity);
var useKinetic = kineticCandidate && Math.abs(rawContentVelocity) >= minVelocity;
velocity = 0;
lastWheelTimestamp = 0;
kineticCandidate = false;
if (useKinetic) {
if (atEdgeFor(rawContentVelocity)) {
// Already at the edge — fling bounces immediately.
pendingKineticBounceVelocity = 0;
triggerKineticBounce(rawContentVelocity);
} else if (Math.abs(contentVelocity) >= minVelocity && runKineticGlide(contentVelocity)) {
// Glide started — bounce fires on kineticAnim.onFinished
// if it lands at an edge.
pendingKineticBounceVelocity = contentVelocity;
} else {
pendingKineticBounceVelocity = 0;
flickable.returnToBounds();
}
} else {
pendingKineticBounceVelocity = 0;
flickable.returnToBounds();
}
}
function handleWheel(wheel) {
wheelHandler.blocking = true;
if (!enabled || !visible || !flickable || !flickable.visible || !flickable.interactive) {
wheelHandler.blocking = false;
return;
}
if (wheel.modifiers !== Qt.NoModifier) {
wheelHandler.blocking = false;
return;
}
if (maximumContentY() <= minimumContentY()) {
wheelHandler.blocking = false;
return;
}
var delta = deltaFor(wheel);
if (delta === 0) {
wheelHandler.blocking = false;
return;
}
// Cancel any in-flight glide — user input always wins, position
// stays where it is, then new delta is applied from there.
if (kineticAnim.running) {
pendingKineticBounceVelocity = 0;
pendingImpactVelocity = 0;
kineticAnim.stop();
}
kineticBounceSequence.stop();
bounceBackAnimation.stop();
if (flickable.flicking) {
pendingKineticBounceVelocity = 0;
pendingImpactVelocity = 0;
flickable.cancelFlick();
}
var now = Date.now();
var isTouchpad = !isMouseWheel(wheel) && (wheel.pixelDelta.y !== 0 || (wheel.phase !== Qt.NoScrollPhase && wheel.angleDelta.y === 0));
console.log("wheel", wheel.angleDelta.y, wheel.pixelDelta.y, wheel.phase);
if (!isTouchpad) {
velocity = 0;
lastWheelTimestamp = 0;
kineticCandidate = false;
scrollStarted();
handleMouseDelta(delta, now);
return;
}
// Touchpad path (unchanged below), but cancel any mouse glide first
stopMouseGlide();
var gap = lastWheelTimestamp > 0 ? now - lastWheelTimestamp : 16;
var dt = Math.max(8, Math.min(24, gap));
var instantVelocity = (delta / dt) * 1000;
kineticCandidate = kineticCandidate || shouldUseKinetic(wheel, gap);
scrollStarted();
scrollBy(delta);
if (hasOvershoot()) {
overshootReleaseVelocity = Math.max(-maxVelocity, Math.min(maxVelocity, instantVelocity));
velocity = 0;
kineticCandidate = false;
lastWheelTimestamp = now;
finishTimer.restart();
return;
}
velocity = lastWheelTimestamp <= 0 ? instantVelocity : velocity * (1.0 - smoothing) + instantVelocity * smoothing;
lastWheelTimestamp = now;
finishTimer.restart();
if (wheel.phase === Qt.ScrollEnd)
finishScroll();
}
function stopMouseGlide() {
mouseGlide.stop();
mouseVelocity = 0;
mouseDir = 0;
mouseStreak = 0;
mouseLastTime = 0;
}
function handleMouseDelta(delta, now) {
var dir = delta > 0 ? 1 : -1;
// Edge or active overshoot: existing rubber-band path
if (hasOvershoot() || atEdgeFor(dir)) {
stopMouseGlide();
scrollBy(delta);
overshootReleaseVelocity = 0;
finishTimer.restart();
return;
}
var continuous = dir === mouseDir && (now - mouseLastTime) <= mouseAccelWindow;
if (!continuous)
mouseVelocity = 0; // new gesture or reversal: start clean
mouseStreak = continuous ? mouseStreak + 1 : 0;
// Compute the gap BEFORE overwriting mouseLastTime
var gap = mouseLastTime > 0 ? Math.max(12, Math.min(mouseAccelWindow, now - mouseLastTime)) : 0;
mouseDir = dir;
mouseLastTime = now;
var accel = Math.min(mouseMaxAccel, 1 + mouseStreak * mouseAccelStep);
// --- velocity update (this is the part that changed) ---
// Part of the notch lands immediately
var instant = delta * mouseSnap;
flickable.contentY = boundedContentY(flickable.contentY + instant);
// The rest becomes glide velocity, stacked onto the current velocity
var glide = delta * (1 - mouseSnap) * accel;
var v = mouseVelocity + glide / mouseGlideTime;
// While notches keep arriving, don't fall below the rate they imply
if (continuous && gap > 0) {
var rate = glide / (gap / 1000);
if (Math.abs(rate) > Math.abs(v))
v = rate;
}
mouseVelocity = Math.max(-maxVelocity, Math.min(maxVelocity, v));
// --- end velocity update ---
if (!mouseGlide.running)
mouseGlide.start();
}
function stepMouseGlide(dt) {
if (!flickable || flickable.dragging) {
stopMouseGlide();
return;
}
dt = Math.min(dt, 0.05);
var decay = Math.exp(-dt / mouseGlideTime);
// Exact integral of v0 * e^(-t/tau) over dt, so it's frame-rate independent
var step = mouseVelocity * mouseGlideTime * (1 - decay);
var next = flickable.contentY + step;
var minY = minimumContentY();
var maxY = maximumContentY();
if (next <= minY || next >= maxY) {
var impact = mouseVelocity;
flickable.contentY = next <= minY ? minY : maxY;
mouseGlide.stop();
mouseVelocity = 0;
mouseDir = 0;
mouseStreak = 0;
triggerKineticBounce(impact); // bounce scales with the real impact speed
return;
}
flickable.contentY = next;
mouseVelocity *= decay;
if (Math.abs(mouseVelocity) < 40) // was 8
stopMouseGlide();
}
FrameAnimation {
id: mouseGlide
onTriggered: root.stepMouseGlide(frameTime)
}
Timer {
id: finishTimer
interval: 45
repeat: false
onTriggered: root.finishScroll()
}
NumberAnimation {
id: kineticAnim
target: root.flickable
property: "contentY"
easing.type: Easing.OutQuart
onFinished: {
// Prefer the impact-time velocity (computed in runKineticGlide)
// so the bounce matches the residual energy at the moment the
// glide hit the edge — slow landings → soft bounce, fast
// landings → big springy bounce.
var bv = Math.abs(root.pendingImpactVelocity) > root.edgeEpsilon ? root.pendingImpactVelocity : root.pendingKineticBounceVelocity;
root.pendingImpactVelocity = 0;
root.pendingKineticBounceVelocity = 0;
if (Math.abs(bv) > root.edgeEpsilon)
root.triggerKineticBounce(bv);
}
}
NumberAnimation {
id: bounceBackAnimation
target: root
property: "overshootInput"
easing.type: Easing.OutCubic
}
SequentialAnimation {
id: kineticBounceSequence
NumberAnimation {
id: kineticBounceIn
target: root
property: "overshootInput"
easing.type: Easing.OutCubic
}
NumberAnimation {
id: kineticBounceOut
target: root
property: "overshootInput"
easing.type: Easing.OutCubic
}
}
Translate {
id: overshootTranslate
y: root.visualOvershoot
}
Connections {
target: root.flickable
function onInteractiveChanged() {
if (!root.flickable.interactive)
root.stopAndSettle();
}
function onContentHeightChanged() {
if (!root.flickable.moving) {
root.flickable.contentY = root.boundedContentY(root.flickable.contentY);
root.bounceBack();
}
}
function onHeightChanged() {
if (!root.flickable.moving) {
root.flickable.contentY = root.boundedContentY(root.flickable.contentY);
root.bounceBack();
}
}
function onVisibleChanged() {
if (!root.flickable.visible)
root.stopAndSettle();
}
function onMovementEnded() {
if (Math.abs(root.pendingKineticBounceVelocity) > root.edgeEpsilon) {
var bounceVelocity = root.pendingKineticBounceVelocity;
root.pendingKineticBounceVelocity = 0;
root.triggerKineticBounce(bounceVelocity);
}
}
}
Component.onCompleted: {
if (!flickable)
return;
flickable.flickDeceleration = 2800;
flickable.maximumFlickVelocity = Math.max(flickable.maximumFlickVelocity, maxVelocity);
flickable.contentItem.transform = [overshootTranslate];
}
}
+15 -261
View File
@@ -4,14 +4,11 @@ pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import Quickshell.Hyprland import ZShell.Services
import QtQuick
import ZShell
import qs.Components import qs.Components
import qs.Modules
import qs.Helpers import qs.Helpers
import qs.Services
import qs.Paths import qs.Paths
import qs.Services
import ZShell.Config import ZShell.Config
Singleton { Singleton {
@@ -19,11 +16,9 @@ Singleton {
readonly property var appCooldownMap: new Map() readonly property var appCooldownMap: new Map()
property alias dnd: props.dnd property alias dnd: props.dnd
property list<Notif> list: [] property alias list: store.rows
property bool loaded readonly property list<NotificationRow> notClosed: store.rows.filter(n => !n.closed)
readonly property list<Notif> notClosed: list.filter(n => !n.closed) readonly property list<NotificationRow> popups: store.rows.filter(n => n.popup)
readonly property list<Notif> popups: list.filter(n => n.popup)
property alias server: server
function shouldThrottle(appName: string): bool { function shouldThrottle(appName: string): bool {
if (props.dnd || ShellState.anySidebarOpen()) if (props.dnd || ShellState.anySidebarOpen())
@@ -46,33 +41,6 @@ Singleton {
return true; return true;
} }
onListChanged: {
if (loaded) {
saveTimer.restart();
}
}
Timer {
id: saveTimer
interval: 1000
onTriggered: storage.setText(JSON.stringify(root.notClosed.map(n => ({
time: n.time,
id: n.id,
summary: n.summary,
body: n.body,
appIcon: n.appIcon,
appName: n.appName,
image: n.image,
expireTimeout: n.expireTimeout,
urgency: n.urgency,
resident: n.resident,
hasActionIcons: n.hasActionIcons,
actions: n.actions
}))))
}
PersistentProperties { PersistentProperties {
id: props id: props
@@ -81,6 +49,12 @@ Singleton {
reloadableId: "notifs" reloadableId: "notifs"
} }
NotificationStore {
id: store
path: `${Paths.state}/notifs.sqlite`
}
NotificationServer { NotificationServer {
id: server id: server
@@ -89,40 +63,12 @@ Singleton {
bodyImagesSupported: true bodyImagesSupported: true
bodyMarkupSupported: true bodyMarkupSupported: true
imageSupported: true imageSupported: true
keepOnReload: false keepOnReload: true
persistenceSupported: true persistenceSupported: true
onNotification: notif => { onNotification: notif => {
notif.tracked = true; notif.tracked = true;
store.insert(notif, root.shouldThrottle(notif.appName));
const is_popup = root.shouldThrottle(notif.appName);
const comp = notifComp.createObject(root, {
popup: is_popup,
notification: notif
});
root.list = [comp, ...root.list];
}
}
FileView {
id: storage
path: `${Paths.state}/notifs.json`
printErrors: false
onLoadFailed: err => {
if (err === FileViewError.FileNotFound) {
root.loaded = true;
Qt.callLater(() => setText("[]"));
}
}
onLoaded: {
const data = JSON.parse(text());
for (const notif of data)
root.list.push(notifComp.createObject(root, notif));
root.list.sort((a, b) => b.time - a.time);
root.loaded = true;
} }
} }
@@ -130,16 +76,12 @@ Singleton {
description: "Clear all notifications" description: "Clear all notifications"
name: "clearnotifs" name: "clearnotifs"
onPressed: { onPressed: store.clear()
for (const notif of root.list.slice())
notif.close();
}
} }
IpcHandler { IpcHandler {
function clear(): void { function clear(): void {
for (const notif of root.list.slice()) store.clear();
notif.close();
} }
function disableDnd(): void { function disableDnd(): void {
@@ -160,192 +102,4 @@ Singleton {
target: "notifs" target: "notifs"
} }
Component {
id: notifComp
Notif {
}
}
component Notif: QtObject {
id: notif
property list<var> actions
property string appIcon
property string appName
property string body
property string cachedImageSource: ""
property bool cachingImage: false
property bool closed
readonly property Connections conn: Connections {
function onActionsChanged(): void {
notif.actions = notif.notification.actions.map(a => ({
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
}));
}
function onAppIconChanged(): void {
notif.appIcon = notif.notification.appIcon;
}
function onAppNameChanged(): void {
notif.appName = notif.notification.appName;
}
function onBodyChanged(): void {
notif.body = notif.notification.body;
}
function onClosed(): void {
notif.close();
}
function onExpireTimeoutChanged(): void {
notif.expireTimeout = notif.notification.expireTimeout;
}
function onHasActionIconsChanged(): void {
notif.hasActionIcons = notif.notification.hasActionIcons;
}
function onHintsChanged(): void {
notif.hints = notif.notification.hints;
}
function onImageChanged(): void {
notif.imageSource = notif.notification.image || "";
notif.image = notif.imageSource;
notif.cacheImageIfNeeded();
}
function onResidentChanged(): void {
notif.resident = notif.notification.resident;
}
function onSummaryChanged(): void {
notif.summary = notif.notification.summary;
}
function onUrgencyChanged(): void {
notif.urgency = notif.notification.urgency;
}
target: notif.notification
}
property real expireTimeout: 5
property bool hasActionIcons
property var hints
property string image
property string imageSource
property var locks: new Set()
property string notifId
property Notification notification
property bool popup
property bool resident
property string summary
property date time: new Date()
readonly property string timeStr: {
const diff = Time.date.getTime() - time.getTime();
const m = Math.floor(diff / 60000);
if (m < 1)
return qsTr("now");
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
if (d > 0)
return `${d}d`;
if (h > 0)
return `${h}h`;
return `${m}m`;
}
readonly property Timer timer: Timer {
property bool paused: false
property int remainingTime: totalTime
property int totalTime: Config.notifs.defaultExpireTimeout
interval: 50
repeat: true
running: !paused
onTriggered: {
remainingTime -= interval;
if (remainingTime <= 0) {
remainingTime = 0;
notif.popup = false;
stop();
}
}
}
property int urgency: NotificationUrgency.Normal
function cacheImageIfNeeded(): void {
const source = imageSource;
if (!source || cachingImage)
return;
if (cachedImageSource === source)
return;
cachingImage = true;
ZShellIo.cacheImage(Qt.resolvedUrl(source), Paths.notifimagecache, (path, url) => {
cachedImageSource = source;
image = path;
cachingImage = false;
}, () => {
cachingImage = false;
});
}
function close(): void {
closed = true;
if (locks.size === 0 && root.list.includes(this)) {
root.list = root.list.filter(n => n !== this);
notification?.dismiss();
destroy();
}
}
function lock(item: Item): void {
locks.add(item);
}
function unlock(item: Item): void {
locks.delete(item);
if (closed)
close();
}
Component.onCompleted: {
if (!notification)
return;
notifId = notification.id;
summary = notification.summary;
body = notification.body;
appIcon = notification.appIcon;
appName = notification.appName;
imageSource = notification.image || "";
image = imageSource;
hints = notification.hints;
expireTimeout = notification.expireTimeout;
urgency = notification.urgency;
resident = notification.resident;
hasActionIcons = notification.hasActionIcons;
actions = notification.actions.map(a => ({
identifier: a.identifier,
text: a.text,
invoke: () => a.invoke()
}));
cacheImageIfNeeded();
}
}
} }
+2 -1
View File
@@ -5,6 +5,7 @@ import Quickshell.Widgets
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import ZShell.Services
import qs.Modules import qs.Modules
import qs.Components import qs.Components
import qs.Helpers import qs.Helpers
@@ -277,7 +278,7 @@ CustomRect {
component NotifLine: CustomText { component NotifLine: CustomText {
id: notifLine id: notifLine
required property NotifServer.Notif modelData required property NotificationRow modelData
Layout.fillWidth: true Layout.fillWidth: true
color: root.urgency === "critical" ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onSurface color: root.urgency === "critical" ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onSurface
+2 -1
View File
@@ -5,6 +5,7 @@ import Quickshell.Widgets
import QtQuick import QtQuick
import ZShell.Config import ZShell.Config
import ZShell.Components import ZShell.Components
import ZShell.Services
import qs.Components import qs.Components
import qs.Daemons import qs.Daemons
import qs.Helpers import qs.Helpers
@@ -97,7 +98,7 @@ Item {
property int idx property int idx
required property int index required property int index
required property NotifServer.Notif modelData required property NotificationRow modelData
readonly property alias nonAnimHeight: notif.nonAnimHeight readonly property alias nonAnimHeight: notif.nonAnimHeight
implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Tokens.spacing.small) implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Tokens.spacing.small)
+13 -8
View File
@@ -5,7 +5,7 @@ import QtQuick.Shapes
import Quickshell import Quickshell
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import ZShell.Components import ZShell.Components
import qs.Daemons import ZShell.Services
import qs.Helpers import qs.Helpers
import ZShell.Config import ZShell.Config
import qs.Components import qs.Components
@@ -17,7 +17,7 @@ CustomRect {
property bool expanded: Config.notifs.openExpanded property bool expanded: Config.notifs.openExpanded
readonly property bool hasAppIcon: modelData.appIcon.length > 0 readonly property bool hasAppIcon: modelData.appIcon.length > 0
readonly property bool hasImage: modelData.image.length > 0 readonly property bool hasImage: modelData.image.length > 0
required property NotifServer.Notif modelData required property NotificationRow modelData
readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? Tokens.spacing.extraSmall * 2 + appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2 readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? Tokens.spacing.extraSmall * 2 + appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2
color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondaryContainer : Colors.tPalette.m3surfaceContainer color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondaryContainer : Colors.tPalette.m3surfaceContainer
@@ -56,10 +56,10 @@ CustomRect {
if (actions.length === 1) if (actions.length === 1)
actions[0].invoke(); actions[0].invoke();
} }
onEntered: root.modelData.timer.stop() onEntered: root.modelData.pausePopup()
onExited: { onExited: {
if (!pressed) if (!pressed)
root.modelData.timer.start(); root.modelData.resumePopup();
} }
onPositionChanged: event => { onPositionChanged: event => {
if (pressed) { if (pressed) {
@@ -69,14 +69,14 @@ CustomRect {
} }
} }
onPressed: event => { onPressed: event => {
root.modelData.timer.stop(); root.modelData.pausePopup();
startY = event.y; startY = event.y;
if (event.button === Qt.MiddleButton) if (event.button === Qt.MiddleButton)
root.modelData.close(); root.modelData.close();
} }
onReleased: event => { onReleased: event => {
if (!containsMouse) if (!containsMouse)
root.modelData.timer.start(); root.modelData.resumePopup();
if (Math.abs(root.x) < root.implicitWidth * Config.notifs.clearThreshold) if (Math.abs(root.x) < root.implicitWidth * Config.notifs.clearThreshold)
root.x = 0; root.x = 0;
@@ -200,7 +200,7 @@ CustomRect {
radiusX: progressIndicator.width / 2 - Tokens.padding.extraSmall / 2 radiusX: progressIndicator.width / 2 - Tokens.padding.extraSmall / 2
radiusY: progressIndicator.height / 2 - Tokens.padding.extraSmall / 2 radiusY: progressIndicator.height / 2 - Tokens.padding.extraSmall / 2
startAngle: -90 startAngle: -90
sweepAngle: ((root.modelData.hints.value ?? 0) / 100) * 360 sweepAngle: (root.modelData.progress / 100) * 360
Behavior on sweepAngle { Behavior on sweepAngle {
Anim { Anim {
@@ -477,7 +477,12 @@ CustomRect {
shapeMorph: true shapeMorph: true
text: modelData.text text: modelData.text
onClicked: modelData.invoke() onClicked: {
if (modelData.invokable)
modelData.invoke();
else if (!root.modelData.resident)
root.modelData.close();
}
} }
} }
+3 -2
View File
@@ -4,16 +4,17 @@ import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import Quickshell import Quickshell
import ZShell.Config import ZShell.Config
import ZShell.Components
import ZShell.Services
import qs.Components import qs.Components
import qs.Services import qs.Services
import qs.Daemons
CustomRect { CustomRect {
id: root id: root
readonly property CustomText body: (expandedContent.item as ExpandedBody)?.body ?? null readonly property CustomText body: (expandedContent.item as ExpandedBody)?.body ?? null
required property bool expanded required property bool expanded
required property NotifServer.Notif modelData required property NotificationRow modelData
readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Tokens.padding.medium * 2 : summaryHeightMetrics.height readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Tokens.padding.medium * 2 : summaryHeightMetrics.height
required property ScreenState screenState required property ScreenState screenState
+4 -4
View File
@@ -5,15 +5,15 @@ import QtQuick.Layouts
import Quickshell import Quickshell
import Quickshell.Widgets import Quickshell.Widgets
import ZShell.Config import ZShell.Config
import ZShell.Services
import qs.Components import qs.Components
import qs.Effects import qs.Effects
import qs.Services import qs.Services
import qs.Daemons
Item { Item {
id: root id: root
required property NotifServer.Notif notif required property NotificationRow notif
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: flickable.contentHeight implicitHeight: flickable.contentHeight
@@ -36,7 +36,7 @@ Item {
{ {
isClose: true isClose: true
}, },
...(root.notif?.actions ?? []), ...Array.from(root.notif?.actions ?? []),
{ {
isCopy: true isCopy: true
} }
@@ -84,7 +84,7 @@ Item {
Quickshell.clipboardText = root.notif.body; Quickshell.clipboardText = root.notif.body;
actionInner.item.text = "inventory"; actionInner.item.text = "inventory";
copyTimer.start(); copyTimer.start();
} else if (action.modelData.invoke) { } else if (action.modelData.invokable) {
action.modelData.invoke(); action.modelData.invoke();
} else if (!root.notif.resident) { } else if (!root.notif.resident) {
root.notif.close(); root.notif.close();
+7 -5
View File
@@ -1,13 +1,14 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import qs.Components
import ZShell.Config
import qs.Modules
import qs.Daemons
import Quickshell import Quickshell
import Quickshell.Widgets import Quickshell.Widgets
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import ZShell.Components
import ZShell.Config
import qs.Components
import qs.Modules
import qs.Daemons
import qs.Services import qs.Services
Item { Item {
@@ -92,13 +93,14 @@ Item {
} }
} }
CustomFlickable { KineticFlickable {
id: view id: view
anchors.fill: parent anchors.fill: parent
contentHeight: notifList.implicitHeight contentHeight: notifList.implicitHeight
contentWidth: width contentWidth: width
flickableDirection: Flickable.VerticalFlick flickableDirection: Flickable.VerticalFlick
clip: true
CustomScrollBar.vertical: CustomScrollBar { CustomScrollBar.vertical: CustomScrollBar {
flickable: view flickable: view
+2 -2
View File
@@ -3,8 +3,8 @@ pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell import Quickshell
import ZShell.Components import ZShell.Components
import ZShell.Services
import ZShell.Config import ZShell.Config
import qs.Daemons
import qs.Components import qs.Components
import qs.Services import qs.Services
@@ -37,7 +37,7 @@ LazyListView {
id: notif id: notif
required property int index required property int index
required property NotifServer.Notif modelData required property NotificationRow modelData
property int startY property int startY
LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight
+20 -53
View File
@@ -14,9 +14,10 @@
#include <hyprland/src/plugins/PluginAPI.hpp> #include <hyprland/src/plugins/PluginAPI.hpp>
#include <hyprland/src/render/OpenGL.hpp> #include <hyprland/src/render/OpenGL.hpp>
#include <hyprland/src/render/Renderer.hpp> #include <hyprland/src/render/Renderer.hpp>
#include <hyprland/src/render/Context.hpp>
#undef private #undef private
#include <hyprland/src/managers/EventManager.hpp> #include <hyprland/src/ipc/s2/S2.hpp>
extern "C" { extern "C" {
#include <lua.h> #include <lua.h>
@@ -27,6 +28,7 @@ extern "C" {
using ZShell::services::kelvinToGain; using ZShell::services::kelvinToGain;
using Render::CRenderContext;
using Render::IFramebuffer; using Render::IFramebuffer;
using Render::GL::CHyprOpenGLImpl; using Render::GL::CHyprOpenGLImpl;
using Render::GL::g_pHyprOpenGL; using Render::GL::g_pHyprOpenGL;
@@ -149,25 +151,14 @@ static bool gainIsNeutral(const std::array<float, 3>& gain) {
} }
static CFunctionHook* g_pBeginHook = nullptr; static CFunctionHook* g_pBeginHook = nullptr;
using origBegin = void (*)( using origBegin = bool (*)(
CHyprOpenGLImpl*, CHyprOpenGLImpl*,
CRenderContext&,
PHLMONITOR, PHLMONITOR,
const CRegion&, const CRegion&,
SP<IFramebuffer>, SP<IFramebuffer>,
std::optional<CRegion>); std::optional<CRegion>);
static CFunctionHook* g_pSaveMirrorHook = nullptr;
using origSaveMirror = bool (*)(CHyprOpenGLImpl*, const CBox&);
static bool hkSaveBufferForMirror(CHyprOpenGLImpl* thisptr, const CBox& box) {
const bool saved = thisptr->m_applyFinalShader;
thisptr->m_applyFinalShader = false;
const bool result = (*reinterpret_cast<origSaveMirror>(
g_pSaveMirrorHook->m_original))(thisptr, box);
thisptr->m_applyFinalShader = saved;
return result;
}
static void installShader() { static void installShader() {
auto* gl = g_pHyprOpenGL.get(); auto* gl = g_pHyprOpenGL.get();
if (!gl) return; if (!gl) return;
@@ -198,34 +189,33 @@ static void setUniformGain(const std::array<float, 3>& gain) {
shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]); shader->setUniformFloat3(SHADER_TINT, gain[0], gain[1], gain[2]);
} }
static void hkBegin( static bool hkBegin(
CHyprOpenGLImpl* thisptr, CHyprOpenGLImpl* thisptr,
CRenderContext& ctx,
PHLMONITOR mon, PHLMONITOR mon,
const CRegion& damage, const CRegion& damage,
SP<IFramebuffer> fb, SP<IFramebuffer> fb,
std::optional<CRegion> finalDamage) { std::optional<CRegion> finalDamage) {
(*reinterpret_cast<origBegin>( const bool ok = (*reinterpret_cast<origBegin>(
g_pBeginHook->m_original))(thisptr, mon, damage, fb, finalDamage); g_pBeginHook->m_original))(thisptr, ctx, mon, damage, fb, finalDamage);
if (!ok) return false;
if (fb) { if (fb) ctx.m_data.blockScreenShader = true;
auto* renderer = g_pHyprRenderer.get();
if (renderer) renderer->m_renderData.blockScreenShader = true;
}
if (!g_active) return; if (!g_active) return true;
auto* gl = g_pHyprOpenGL.get(); auto* gl = g_pHyprOpenGL.get();
if (!gl) return; if (!gl) return true;
if (gl->m_finalScreenShader.get() && gl->m_finalScreenShader->program() < 1) if (gl->m_finalScreenShader.get() && gl->m_finalScreenShader->program() < 1)
installShader(); installShader();
if (!gl->m_finalScreenShader.get() || if (!gl->m_finalScreenShader.get() ||
gl->m_finalScreenShader->program() < 1) gl->m_finalScreenShader->program() < 1)
return; return true;
if (g_transition.active) { if (g_transition.active) {
auto* monPtr = mon.get(); auto* monPtr = mon.get();
if (!monPtr) return; if (!monPtr) return true;
const float elapsed = const float elapsed =
std::chrono::duration<float>( std::chrono::duration<float>(
std::chrono::steady_clock::now() - g_transition.start) std::chrono::steady_clock::now() - g_transition.start)
@@ -265,6 +255,7 @@ static void hkBegin(
} else if (g_uniformMode) { } else if (g_uniformMode) {
setUniformGain(g_currentGain); setUniformGain(g_currentGain);
} }
return true;
} }
static constexpr int KELVIN_MIN = 2000; static constexpr int KELVIN_MIN = 2000;
@@ -333,9 +324,10 @@ static int luaNightlightState(lua_State* L) {
} }
static void postStateEvent(bool enabled, bool animating) { static void postStateEvent(bool enabled, bool animating) {
if (!g_pEventManager) return; auto& sock = IPC::Socket2::sock();
g_pEventManager->postEvent( if (!sock) return;
SHyprIPCEvent{ sock->postEvent(
IPC::Socket2::SEvent{
.event = "zshell-nightlight", .event = "zshell-nightlight",
.data = std::format( .data = std::format(
"{},{},{},{}", "{},{},{},{}",
@@ -377,31 +369,6 @@ APICALL EXPORT PLUGIN_DESCRIPTION_INFO PLUGIN_INIT(HANDLE handle) {
throw std::runtime_error("[zshell-nightlight] version mismatch"); throw std::runtime_error("[zshell-nightlight] version mismatch");
} }
static const auto SAVEMIRROR_METHODS =
HyprlandAPI::findFunctionsByName(PHANDLE, "saveBufferForMirror");
const SFunctionMatch* saveMirrorTarget = findExactMethod(
SAVEMIRROR_METHODS,
manglePrefix("CHyprOpenGLImpl", "saveBufferForMirror"));
if (saveMirrorTarget) {
g_pSaveMirrorHook = HyprlandAPI::createFunctionHook(
PHANDLE,
saveMirrorTarget->address,
reinterpret_cast<void*>(&hkSaveBufferForMirror));
if (g_pSaveMirrorHook && g_pSaveMirrorHook->hook())
dlog(
"saveBufferForMirror hook installed: " +
saveMirrorTarget->signature);
}
if (!g_pSaveMirrorHook)
HyprlandAPI::addNotification(
PHANDLE,
"[zshell-nightlight] WARNING: couldn't hook saveBufferForMirror — "
"captures may still show the nightlight",
CHyprColor{1.f, 0.6f, 0.2f, 1.f},
8000);
static const auto METHODS = static const auto METHODS =
HyprlandAPI::findFunctionsByName(PHANDLE, "begin"); HyprlandAPI::findFunctionsByName(PHANDLE, "begin");
const SFunctionMatch* target = const SFunctionMatch* target =
+3
View File
@@ -23,11 +23,14 @@ qml_module(ZShell-services
lyriccandidate.hpp lyriccandidate.cpp lyriccandidate.hpp lyriccandidate.cpp
lyrics.hpp lyrics.cpp lyrics.hpp lyrics.cpp
dropimporter.hpp dropimporter.cpp dropimporter.hpp dropimporter.cpp
notificationstore.hpp notificationstore.cpp
LIBRARIES LIBRARIES
Qt6::Core Qt6::Core
Qt6::Qml Qt6::Qml
Qt6::Quick
Qt6::Concurrent Qt6::Concurrent
Qt6::Network Qt6::Network
Qt6::Sql
PkgConfig::Pipewire PkgConfig::Pipewire
PkgConfig::Aubio PkgConfig::Aubio
PkgConfig::Cava PkgConfig::Cava
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,248 @@
#pragma once
#include <QDateTime>
#include <QHash>
#include <QJSValue>
#include <QList>
#include <QObject>
#include <QPointer>
#include <qqmlintegration.h>
#include <QQmlListProperty>
#include <QSet>
#include <QString>
#include <QTimer>
namespace ZShell::config {
class Notifs;
}
namespace ZShell::Services {
class NotificationStore;
class NotifImageProvider;
class NotificationActionRow : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE(
"NotificationActionRow instances come from a NotificationStore")
Q_PROPERTY(bool invokable READ invokable NOTIFY invokableChanged)
Q_PROPERTY(QString identifier READ identifier CONSTANT)
Q_PROPERTY(QString text READ text CONSTANT)
public:
NotificationActionRow(
QObject* action,
const QString& identifier,
const QString& text,
QObject* parent = nullptr);
[[nodiscard]] bool invokable() const;
[[nodiscard]] QString identifier() const;
[[nodiscard]] QString text() const;
Q_INVOKABLE void invoke();
signals:
void invokableChanged();
private:
QPointer<QObject> m_action;
QString m_identifier;
QString m_text;
};
class NotificationRow : public QObject {
Q_OBJECT
QML_ELEMENT
QML_UNCREATABLE("NotificationRow instances come from a NotificationStore")
Q_PROPERTY(QQmlListProperty<NotificationActionRow> actions READ actions NOTIFY actionsChanged)
Q_PROPERTY(QString appIcon READ appIcon NOTIFY appIconChanged)
Q_PROPERTY(QString appName READ appName NOTIFY appNameChanged)
Q_PROPERTY(QString body READ body NOTIFY bodyChanged)
Q_PROPERTY(bool closed READ closed NOTIFY closedChanged)
Q_PROPERTY(bool hasActionIcons READ hasActionIcons NOTIFY hasActionIconsChanged)
Q_PROPERTY(QString image READ image NOTIFY imageChanged)
Q_PROPERTY(QString imageSource READ imageSource NOTIFY imageSourceChanged)
Q_PROPERTY(int notificationId READ notificationId CONSTANT)
Q_PROPERTY(int progress READ progress NOTIFY progressChanged)
Q_PROPERTY(bool popup READ popup WRITE setPopup NOTIFY popupChanged)
Q_PROPERTY(bool resident READ resident NOTIFY residentChanged)
Q_PROPERTY(QString summary READ summary NOTIFY summaryChanged)
Q_PROPERTY(QDateTime time READ time CONSTANT)
Q_PROPERTY(QString timeStr READ timeStr NOTIFY timeStrChanged)
Q_PROPERTY(int urgency READ urgency NOTIFY urgencyChanged)
public:
explicit NotificationRow(QObject* parent = nullptr);
[[nodiscard]] QQmlListProperty<NotificationActionRow> actions();
[[nodiscard]] QString appIcon() const;
[[nodiscard]] QString appName() const;
[[nodiscard]] QString body() const;
[[nodiscard]] bool closed() const;
[[nodiscard]] bool hasActionIcons() const;
[[nodiscard]] QString image() const;
[[nodiscard]] QString imageSource() const;
[[nodiscard]] int notificationId() const;
[[nodiscard]] int progress() const;
[[nodiscard]] bool popup() const;
void setPopup(bool popup);
[[nodiscard]] bool resident() const;
[[nodiscard]] QString summary() const;
[[nodiscard]] QDateTime time() const;
[[nodiscard]] QString timeStr() const;
[[nodiscard]] int urgency() const;
Q_INVOKABLE void close();
Q_INVOKABLE void lock(QObject* item);
Q_INVOKABLE void pausePopup();
Q_INVOKABLE void resumePopup();
Q_INVOKABLE void unlock(QObject* item);
signals:
void actionsChanged();
void appIconChanged();
void appNameChanged();
void bodyChanged();
void closedChanged();
void hasActionIconsChanged();
void imageChanged();
void imageSourceChanged();
void popupChanged();
void progressChanged();
void residentChanged();
void summaryChanged();
void timeStrChanged();
void urgencyChanged();
private:
friend NotificationStore;
[[nodiscard]] bool expired(qint64 now) const;
void refreshTimeStr();
QList<NotificationActionRow*> m_actions;
NotificationStore* m_store = nullptr;
QString m_appIcon;
QString m_appName;
QString m_body;
QString m_digest;
QString m_image;
QString m_imageSource;
QString m_summary;
QString m_timeStr;
qint64 m_deadline = 0;
qint64 m_rowId = -1;
qint64 m_timeMs = 0;
QPointer<QObject> m_notification;
QSet<QObject*> m_locks;
QJSValue m_jsNotification;
bool m_closed = false;
bool m_hasActionIcons = false;
bool m_liveConnected = false;
bool m_popup = false;
bool m_popupPaused = false;
bool m_resident = false;
int m_notificationId = 0;
int m_progress = 0;
int m_urgency = 1;
};
class NotificationStore : public QObject {
Q_OBJECT
QML_ELEMENT
Q_PROPERTY(bool autoExpire READ autoExpire WRITE setAutoExpire NOTIFY autoExpireChanged)
Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED)
Q_PROPERTY(int popupTimeout READ popupTimeout WRITE setPopupTimeout NOTIFY
popupTimeoutChanged)
Q_PROPERTY(QQmlListProperty<NotificationRow> rows READ rows NOTIFY rowsChanged)
public:
explicit NotificationStore(QObject* parent = nullptr);
~NotificationStore() override;
[[nodiscard]] QString path() const;
void setPath(const QString& path);
[[nodiscard]] bool autoExpire() const;
void setAutoExpire(bool autoExpire);
[[nodiscard]] int popupTimeout() const;
void setPopupTimeout(int timeout);
[[nodiscard]] QQmlListProperty<NotificationRow> rows();
Q_INVOKABLE NotificationRow* insert(const QJSValue& notification, bool popup);
Q_INVOKABLE void clear();
signals:
void autoExpireChanged();
void pathChanged();
void popupTimeoutChanged();
void rowsChanged();
private slots:
void handleLiveUpdate();
private:
friend NotificationRow;
void attachNotification(
NotificationRow* row, QObject* notification, const QJSValue& js);
void applyNotifConfig(const config::Notifs* notifs);
void closeDb();
void copyFields(NotificationRow* row, QObject* notification);
void handleClosed(NotificationRow* row);
void insertDbRow(NotificationRow* row);
void loadRows();
void migrateLegacyJson();
[[nodiscard]] bool openDb();
void orphanCleanup();
void persistPopup(const NotificationRow* row);
void prune();
void registerProvider();
void removeRow(NotificationRow* row);
// Coalesced rowsChanged: a burst of inserts/removes triggers one QML
// rebind on the next event loop turn instead of one per notification.
void scheduleRowsChanged();
[[nodiscard]] NotificationRow* rowForNotificationId(int id) const;
void startImageCache(NotificationRow* row);
void storeImageBlob(
const QPointer<NotificationRow>& row,
const QString& digest,
const QByteArray& png);
void tick();
void trimRows();
void updateDbRow(NotificationRow* row);
static QList<NotificationActionRow*> parseActions(
const QJSValue& actions, NotificationRow* row);
int m_popupTimeout = 5000;
qint64 m_nextPrune = 0;
qint64 m_nextTimeStrRefresh = 0;
bool m_autoExpire = true;
bool m_clearing = false;
bool m_rowsSignalPending = false;
QString m_uuid;
QString m_path;
QList<NotificationRow*> m_rows;
QHash<QObject*, NotificationRow*> m_liveRows;
NotifImageProvider* m_provider = nullptr;
QTimer* m_tick = nullptr;
};
} // namespace ZShell::Services
+9 -7
View File
@@ -545,7 +545,7 @@ def generate(
except (PermissionError, OSError, BlockingIOError): except (PermissionError, OSError, BlockingIOError):
pass pass
def smart_mode(image_path: Path) -> str: def smart_mode(image_path: Path | io.BytesIO) -> str:
is_dark = "" is_dark = ""
with Image.open(image_path) as img: with Image.open(image_path) as img:
@@ -739,7 +739,7 @@ def generate(
return rendered_outputs return rendered_outputs
def seed_from_image(image_path: Path) -> Hct: def seed_from_image(image_path: Path | io.BytesIO) -> Hct:
image = Image.open(image_path) image = Image.open(image_path)
pixel_len = image.width * image.height pixel_len = image.width * image.height
image_data = image.getdata() image_data = image.getdata()
@@ -837,11 +837,13 @@ def generate(
image_path = image_path or Path(WALL_PATH) image_path = image_path or Path(WALL_PATH)
blob = scheme_thumb_from_db(str(DB_PATH), str(image_path)) blob = scheme_thumb_from_db(str(DB_PATH), str(image_path))
if blob is not None:
open_thumb = lambda: io.BytesIO(blob) def open_thumb():
else: if blob is not None:
thumb_path = generate_thumbnail(image_path, THUMB_DIR) return io.BytesIO(blob)
open_thumb = lambda: thumb_path else:
thumb_path = generate_thumbnail(image_path, THUMB_DIR)
return thumb_path
seed = seed_from_image(open_thumb()) seed = seed_from_image(open_thumb())
name = "dynamic" name = "dynamic"