From 401b46c88639e84e5cd33ec53aaf4aafa9858840 Mon Sep 17 00:00:00 2001 From: Alan Silva Date: Tue, 8 Sep 2026 16:55:43 +0100 Subject: [PATCH] Attention Required: notification rules with effects for Omarchy Rules watch every notification for words and apps and answer with an effect: nudge, flash, banner, airplane, confetti, blink, sound, focus, or a command. Silenced notifications that match a rule are let through. Settings popup behind a bar bell, rules kept in one JSON file. --- .gitignore | 2 + Airplane.qml | 235 ++++++++++ Banner.qml | 145 ++++++ Blink.qml | 72 +++ Confetti.qml | 169 +++++++ EffectCatalog.js | 131 ++++++ Flash.qml | 131 ++++++ FrameTicker.qml | 62 +++ LICENSE | 21 + README.md | 144 ++++++ Rules.js | 209 +++++++++ Service.qml | 657 ++++++++++++++++++++++++++ Settings.qml | 1016 ++++++++++++++++++++++++++++++++++++++++ bin/ar-effect | Bin 0 -> 2173 bytes bin/ar-watch | 120 +++++ bin/attention-required | 163 +++++++ demo.sh | 111 +++++ effects/command | 9 + effects/focus | 42 ++ effects/nudge | 90 ++++ effects/sound | 35 ++ install.sh | 56 +++ manifest.json | 28 ++ preview.png | Bin 0 -> 1501946 bytes rules.example.json | 18 + tests/rules.test.js | 61 +++ 26 files changed, 3727 insertions(+) create mode 100644 .gitignore create mode 100644 Airplane.qml create mode 100644 Banner.qml create mode 100644 Blink.qml create mode 100644 Confetti.qml create mode 100644 EffectCatalog.js create mode 100644 Flash.qml create mode 100644 FrameTicker.qml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rules.js create mode 100644 Service.qml create mode 100644 Settings.qml create mode 100755 bin/ar-effect create mode 100755 bin/ar-watch create mode 100755 bin/attention-required create mode 100755 demo.sh create mode 100755 effects/command create mode 100755 effects/focus create mode 100755 effects/nudge create mode 100755 effects/sound create mode 100755 install.sh create mode 100644 manifest.json create mode 100644 preview.png create mode 100644 rules.example.json create mode 100644 tests/rules.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1226711 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.bak +*.bak.* diff --git a/Airplane.qml b/Airplane.qml new file mode 100644 index 0000000..8871469 --- /dev/null +++ b/Airplane.qml @@ -0,0 +1,235 @@ +import QtQuick +import QtQuick.Particles +import Quickshell +import Quickshell.Wayland +import qs.Commons +import "Rules.js" as Rules + +// A little plane flies across every screen towing a flag with the message. +// The plane bobs and pitches on the air, leaves a trail, and the flag +// ripples behind it: it is drawn once, then shown as a row of thin slices +// each riding its own bit of a travelling wave. +// +// Options: duration (flight time in seconds, 7), intensity (size, 1), +// altitude (0..1 from the top, 0.2), direction (ltr | rtl), text +// ("{summary}" template). +Item { + id: root + width: 0 + height: 0 + visible: false + + property bool active: false + property real flight: 0 // 0 at take-off, 1 when gone + property real phase: 0 // the wave running down the flag + property string text: "" + property string direction: "ltr" + property real size: 1 + property real altitude: 0.2 + property int flightMs: 7000 + + function number(value, fallback, min, max) { + var n = Number(value) + if (!isFinite(n)) return fallback + return Math.max(min, Math.min(max, n)) + } + + function trigger(opts, notif, rule) { + opts = opts || {} + text = Rules.renderTemplate(opts.text, notif, rule) + direction = opts.direction === "rtl" ? "rtl" : "ltr" + size = number(opts.intensity, 1, 0.5, 3) + altitude = number(opts.altitude, 0.2, 0.05, 0.95) + flightMs = Math.round(number(opts.duration, 7, 2, 20) * 1000) + anim.stop() + flight = 0 + active = true + anim.duration = flightMs + anim.start() + } + + NumberAnimation { + id: anim + target: root + property: "flight" + from: 0 + to: 1 + onFinished: root.active = false + } + + NumberAnimation on phase { + running: root.active + from: 0 + to: Math.PI * 2 + duration: 520 + loops: Animation.Infinite + } + + // The bob is a slow sine over the flight; the pitch follows its slope so + // the nose points where the plane is going. + readonly property real bobCycles: 3.2 + readonly property real bob: Math.sin(root.flight * Math.PI * 2 * bobCycles) + readonly property real slope: Math.cos(root.flight * Math.PI * 2 * bobCycles) + + Variants { + model: Quickshell.screens + + PanelWindow { + id: window + required property var modelData + screen: modelData + visible: root.active + color: "transparent" + anchors { top: true; bottom: true; left: true; right: true } + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "attention-required-airplane" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + mask: Region {} + + readonly property bool ltr: root.direction === "ltr" + readonly property real bobPx: Style.space(14) * root.size + + Item { + id: convoy + width: row.implicitWidth + height: row.implicitHeight + x: window.ltr ? Math.round(-width + root.flight * (window.width + width)) + : Math.round(window.width - root.flight * (window.width + width)) + y: Math.round(root.altitude * window.height + root.bob * window.bobPx) + + Row { + id: row + spacing: 0 + layoutDirection: window.ltr ? Qt.RightToLeft : Qt.LeftToRight + + // ---- the plane ---- + Item { + id: planeBox + width: plane.implicitHeight + height: plane.implicitHeight + anchors.verticalCenter: parent.verticalCenter + + Text { + id: plane + anchors.centerIn: parent + text: "󰀝" + color: Color.foreground + font.family: Style.font.family + font.pixelSize: Math.round(Style.font.display * 2.2 * root.size) + // The glyph points north-east. Turn it 45° to fly east and pitch + // it with the bob; a westbound plane is the same, mirrored, so + // it stays the right way up. + transform: [ + Rotation { + origin.x: plane.width / 2 + origin.y: plane.height / 2 + angle: 45 - root.slope * 9 + }, + Scale { + origin.x: plane.width / 2 + origin.y: plane.height / 2 + xScale: window.ltr ? 1 : -1 + } + ] + } + + // Exhaust: soft puffs that drift back and fade. + ParticleSystem { + id: exhaust + anchors.fill: parent + running: root.active + Emitter { + x: window.ltr ? 0 : planeBox.width + y: planeBox.height * 0.55 + width: 1 + height: 1 + enabled: root.active && root.flight > 0.01 && root.flight < 0.99 + emitRate: 26 + lifeSpan: 900 + lifeSpanVariation: 300 + size: Math.round(Style.space(9) * root.size) + sizeVariation: Math.round(Style.space(4) * root.size) + endSize: Math.round(Style.space(18) * root.size) + velocity: AngleDirection { + angle: window.ltr ? 180 : 0 + angleVariation: 12 + magnitude: Style.space(70) * root.size + magnitudeVariation: Style.space(30) * root.size + } + } + ImageParticle { + source: "qrc:///particleresources/fuzzydot.png" + color: Color.foreground + alpha: 0.35 + alphaVariation: 0.1 + } + } + } + + // ---- the rope ---- + Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: Math.round(Style.space(36) * root.size) + height: Math.max(2, Math.round(Style.space(2) * root.size)) + color: Color.foreground + opacity: 0.7 + rotation: root.slope * 3 + } + + // ---- the flag, rippling ---- + Item { + id: flag + anchors.verticalCenter: parent.verticalCenter + readonly property int slices: 28 + readonly property real amp: Style.space(5) * root.size + readonly property real sliceW: Math.ceil(card.width / slices) + width: card.width + height: card.height + amp * 2 + + // Drawn once, off to the side of what is shown. + Rectangle { + id: card + visible: true + opacity: 0 + width: label.implicitWidth + Math.round(Style.space(28) * root.size) + height: label.implicitHeight + Math.round(Style.space(16) * root.size) + color: Color.popups.background + border.width: Math.max(2, Math.round(Style.space(2) * root.size)) + border.color: Color.accent + radius: Math.round(Style.space(4) * root.size) + Text { + id: label + anchors.centerIn: parent + text: root.text + color: Color.popups.text + font.family: Style.font.family + font.pixelSize: Math.round(Style.font.display * root.size) + font.weight: Font.DemiBold + } + } + + Repeater { + model: flag.slices + delegate: ShaderEffectSource { + required property int index + // The wave grows towards the free end of the flag, which is + // the end away from the rope. + readonly property real along: window.ltr ? (flag.slices - 1 - index) / (flag.slices - 1) : index / (flag.slices - 1) + sourceItem: card + sourceRect: Qt.rect(index * flag.sliceW, 0, flag.sliceW, card.height) + width: flag.sliceW + 1 + height: card.height + x: index * flag.sliceW + y: flag.amp + Math.sin(root.phase + along * 7) * flag.amp * (0.15 + along) + // Live, so a new message on the next flight is what shows. + live: true + smooth: true + } + } + } + } + } + } + } +} diff --git a/Banner.qml b/Banner.qml new file mode 100644 index 0000000..b327fb8 --- /dev/null +++ b/Banner.qml @@ -0,0 +1,145 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Commons +import "Rules.js" as Rules + +// A big card with the message that slides in, stays, and slides out. On +// every screen, click-through, reserves no space. +// +// Options: duration (seconds it stays, 3), intensity (size, 1), speed +// (slide speed, 4), position (top | center | bottom), color (accent | +// urgent | foreground or any CSS color), text ("{summary}" template). +Item { + id: root + width: 0 + height: 0 + visible: false + + property bool active: false + property real progress: 0 + property string title: "" + property string body: "" + property string position: "top" + property real size: 1 + property color glow: Color.accent + property int slideMs: 250 + property int holdMs: 3000 + + function number(value, fallback, min, max) { + var n = Number(value) + if (!isFinite(n)) return fallback + return Math.max(min, Math.min(max, n)) + } + + function resolveColor(value) { + var v = String(value || "").trim().toLowerCase() + if (!v || v === "accent") return Color.accent + if (v === "urgent") return Color.urgent + if (v === "foreground" || v === "text") return Color.foreground + var c = Qt.color(value) + return c.valid === false ? Color.accent : c + } + + function trigger(opts, notif, rule) { + opts = opts || {} + var custom = String(opts.text || "").trim() + title = Rules.renderTemplate(custom, notif, rule) + body = custom ? "" : Rules.stripTags(notif ? notif.body : "") + position = opts.position === "center" || opts.position === "bottom" ? String(opts.position) : "top" + size = number(opts.intensity, 1, 0.5, 2.5) + slideMs = Math.round(1000 / number(opts.speed, 4, 1, 10)) + holdMs = Math.round(number(opts.duration, 3, 0.5, 30) * 1000) + glow = resolveColor(opts.color) + off.stop() + hide.stop() + active = true + progress = 1 + hide.interval = holdMs + hide.restart() + } + + Behavior on progress { NumberAnimation { duration: root.slideMs; easing.type: Easing.OutCubic } } + + Timer { + id: hide + onTriggered: { + root.progress = 0 + off.interval = root.slideMs + 60 + off.restart() + } + } + Timer { + id: off + onTriggered: root.active = false + } + + Variants { + model: Quickshell.screens + + PanelWindow { + id: window + required property var modelData + screen: modelData + visible: root.active + color: "transparent" + anchors { top: true; bottom: true; left: true; right: true } + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "attention-required-banner" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + mask: Region {} + + readonly property int margin: Style.space(24) + + Rectangle { + id: card + readonly property int pad: Math.round(Style.space(18) * root.size) + width: Math.min(window.width * 0.7, column.implicitWidth + pad * 2) + height: column.implicitHeight + pad * 2 + anchors.horizontalCenter: parent.horizontalCenter + y: root.position === "top" ? Math.round(-height + root.progress * (height + window.margin)) + : root.position === "bottom" ? Math.round(window.height - root.progress * (height + window.margin)) + : Math.round((window.height - height) / 2) + opacity: root.position === "center" ? root.progress : 1 + color: Color.popups.background + radius: Style.cornerRadius + border.width: Math.max(2, Style.space(2)) + border.color: root.glow + + Column { + id: column + anchors.centerIn: parent + width: Math.min(window.width * 0.7 - card.pad * 2, Math.max(titleText.implicitWidth, bodyText.visible ? bodyText.implicitWidth : 0)) + spacing: Math.round(Style.space(6) * root.size) + + Text { + id: titleText + width: parent.width + text: root.title + color: Color.popups.text + font.family: Style.font.family + font.pixelSize: Math.round(Style.font.display * 1.4 * root.size) + font.weight: Font.DemiBold + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + } + Text { + id: bodyText + visible: root.body.length > 0 + width: parent.width + text: root.body + color: Color.popups.text + opacity: 0.8 + font.family: Style.font.family + font.pixelSize: Math.round(Style.font.body * 1.2 * root.size) + wrapMode: Text.Wrap + maximumLineCount: 4 + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + } + } + } + } + } +} diff --git a/Blink.qml b/Blink.qml new file mode 100644 index 0000000..af2ccce --- /dev/null +++ b/Blink.qml @@ -0,0 +1,72 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Commons + +// Every screen dims and comes back, once or a few times. +// +// Options: duration (seconds, 1), intensity (how dark, 0.6), speed +// (blinks per second, 2). +Item { + id: root + width: 0 + height: 0 + visible: false + + property bool active: false + property real level: 0 + property real darkness: 0.6 + property int pulseMs: 500 + + function number(value, fallback, min, max) { + var n = Number(value) + if (!isFinite(n)) return fallback + return Math.max(min, Math.min(max, n)) + } + + function trigger(opts, notif, rule) { + opts = opts || {} + var duration = number(opts.duration, 1, 0.2, 5) + var speed = number(opts.speed, 2, 1, 10) + darkness = number(opts.intensity, 0.6, 0.1, 1) + pulseMs = Math.round(1000 / speed) + anim.stop() + level = 0 + active = true + anim.loops = Math.max(1, Math.round(duration * speed)) + anim.start() + } + + SequentialAnimation { + id: anim + NumberAnimation { target: root; property: "level"; from: 0; to: 1; duration: root.pulseMs / 2; easing.type: Easing.OutQuad } + NumberAnimation { target: root; property: "level"; from: 1; to: 0; duration: root.pulseMs / 2; easing.type: Easing.InQuad } + onFinished: { + root.level = 0 + root.active = false + } + } + + Variants { + model: Quickshell.screens + + PanelWindow { + required property var modelData + screen: modelData + visible: root.active + color: "transparent" + anchors { top: true; bottom: true; left: true; right: true } + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "attention-required-blink" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + mask: Region {} + + Rectangle { + anchors.fill: parent + color: Color.background + opacity: root.level * root.darkness + } + } + } +} diff --git a/Confetti.qml b/Confetti.qml new file mode 100644 index 0000000..6c4946a --- /dev/null +++ b/Confetti.qml @@ -0,0 +1,169 @@ +import QtQuick +import QtQuick.Particles +import Quickshell +import Quickshell.Wayland +import qs.Commons + +// Confetti on every screen, in the theme's colours. +// +// Options: duration (seconds it keeps coming, 1), intensity (amount, 1), +// speed (launch power, 1), style: "cannons" (shot up from the bottom +// corners, the default), "burst" (out from the centre), "rain" (falls +// from the top). +Item { + id: root + width: 0 + height: 0 + visible: false + + property bool active: false + property bool emitting: false + property real amount: 1 + property real power: 1 + property string style: "cannons" + readonly property int lifeMs: 4500 + + function number(value, fallback, min, max) { + var n = Number(value) + if (!isFinite(n)) return fallback + return Math.max(min, Math.min(max, n)) + } + + function trigger(opts, notif, rule) { + opts = opts || {} + amount = number(opts.intensity, 1, 0.2, 3) + power = number(opts.speed, 1, 0.3, 3) + style = opts.style === "rain" || opts.style === "burst" ? String(opts.style) : "cannons" + var seconds = number(opts.duration, 1, 0.5, 15) + active = true + emitting = true + stopEmit.interval = Math.round(seconds * 1000) + stopEmit.restart() + off.interval = Math.round(seconds * 1000) + lifeMs + off.restart() + } + + Timer { id: stopEmit; onTriggered: root.emitting = false } + Timer { id: off; onTriggered: root.active = false } + + readonly property var palette: [ + Color.accent, Qt.lighter(Color.accent, 1.4), Color.urgent, Qt.lighter(Color.urgent, 1.5), + Color.foreground, Qt.darker(Color.accent, 1.4) + ] + + Variants { + model: Quickshell.screens + + PanelWindow { + id: window + required property var modelData + screen: modelData + visible: root.active + color: "transparent" + anchors { top: true; bottom: true; left: true; right: true } + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "attention-required-confetti" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + mask: Region {} + + // Launch speed for the cannons and the burst: enough to reach most of + // the way up the screen against the gravity below. + readonly property real launch: Math.sqrt(2 * gravity.magnitude * window.height * 0.8) * root.power + readonly property bool cannons: root.style === "cannons" + readonly property bool burst: root.style === "burst" + readonly property bool rain: root.style === "rain" + + ParticleSystem { + id: system + anchors.fill: parent + running: root.active + + // Cannon in the bottom-left corner, firing up and to the right. + Emitter { + x: 0 + y: window.height + width: 1 + height: 1 + enabled: root.emitting && window.cannons + emitRate: Math.round(70 * root.amount) + lifeSpan: root.lifeMs + lifeSpanVariation: 800 + size: Style.space(10) + sizeVariation: Style.space(6) + velocity: AngleDirection { angle: 295; angleVariation: 18; magnitude: window.launch; magnitudeVariation: window.launch * 0.25 } + } + // Cannon in the bottom-right corner, firing up and to the left. + Emitter { + x: window.width + y: window.height + width: 1 + height: 1 + enabled: root.emitting && window.cannons + emitRate: Math.round(70 * root.amount) + lifeSpan: root.lifeMs + lifeSpanVariation: 800 + size: Style.space(10) + sizeVariation: Style.space(6) + velocity: AngleDirection { angle: 245; angleVariation: 18; magnitude: window.launch; magnitudeVariation: window.launch * 0.25 } + } + // Burst from the middle, in every direction. + Emitter { + x: window.width / 2 + y: window.height / 2 + width: 1 + height: 1 + enabled: root.emitting && window.burst + emitRate: Math.round(160 * root.amount) + lifeSpan: root.lifeMs + lifeSpanVariation: 800 + size: Style.space(10) + sizeVariation: Style.space(6) + velocity: AngleDirection { angle: 270; angleVariation: 180; magnitude: window.launch * 0.6; magnitudeVariation: window.launch * 0.3 } + } + // Rain from the top edge. + Emitter { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 1 + enabled: root.emitting && window.rain + emitRate: Math.round(90 * root.amount) + lifeSpan: root.lifeMs + lifeSpanVariation: 800 + size: Style.space(10) + sizeVariation: Style.space(6) + velocity: AngleDirection { angle: 90; angleVariation: 30; magnitude: Style.space(120) * root.power; magnitudeVariation: Style.space(80) } + } + + Gravity { + id: gravity + anchors.fill: parent + angle: 90 + magnitude: window.rain ? Style.space(90) : Style.space(900) + } + Wander { + anchors.fill: parent + xVariance: Style.space(140) + pace: Style.space(120) + } + + ItemParticle { + delegate: Rectangle { + width: Style.space(6) + Math.random() * Style.space(8) + height: Style.space(4) + Math.random() * Style.space(5) + radius: Style.space(1) + color: root.palette[Math.floor(Math.random() * root.palette.length)] + rotation: Math.random() * 360 + RotationAnimation on rotation { + loops: Animation.Infinite + from: 0 + to: 360 + duration: 700 + Math.random() * 900 + } + } + } + } + } + } +} diff --git a/EffectCatalog.js b/EffectCatalog.js new file mode 100644 index 0000000..9d5540b --- /dev/null +++ b/EffectCatalog.js @@ -0,0 +1,131 @@ +// Every effect the settings popup knows how to configure, in the order the +// chips are shown. `rows` are sliders (duration / intensity / speed and the +// odd extra), `options` are choices or free text. Scripts in effects/ and +// overlays in the shell read the same keys. +// +// Not a .pragma library: the shell caches those across plugin reloads. + +var EFFECTS = [ + { + type: "nudge", label: "Nudge", icon: "󰕦", + subtitle: "Shakes the screen, like a phone buzzing", + rows: [ + { key: "duration", label: "Duration", min: 0.2, max: 5, step: 0.1, fallback: 1, unit: " s" }, + { key: "intensity", label: "Intensity", min: 0.5, max: 30, step: 0.5, fallback: 1.5, unit: "" }, + { key: "speed", label: "Speed", min: 10, max: 400, step: 10, fallback: 200, unit: " /s" } + ], + options: [] + }, + { + type: "flash", label: "Flash", icon: "󰉁", + subtitle: "A glow pulses in from the edges of the screen", + rows: [ + { key: "duration", label: "Duration", min: 0.2, max: 10, step: 0.1, fallback: 1, unit: " s" }, + { key: "intensity", label: "Intensity", min: 0.05, max: 1, step: 0.05, fallback: 0.9, unit: "" }, + { key: "speed", label: "Pulses", min: 0.5, max: 12, step: 0.5, fallback: 3, unit: " /s" }, + { key: "thickness", label: "Thickness", min: 8, max: 400, step: 8, fallback: 64, unit: " px" } + ], + options: [ + { key: "color", label: "Color", type: "enum", fallback: "accent", + values: [{ value: "accent", label: "Accent" }, { value: "urgent", label: "Urgent" }, { value: "foreground", label: "Text" }] } + ] + }, + { + type: "banner", label: "Banner", icon: "", + subtitle: "The message drops in as a big card", + rows: [ + { key: "duration", label: "Stays for", min: 0.5, max: 30, step: 0.5, fallback: 3, unit: " s" }, + { key: "intensity", label: "Size", min: 0.5, max: 2.5, step: 0.1, fallback: 1, unit: "×" }, + { key: "speed", label: "Slide", min: 1, max: 10, step: 0.5, fallback: 4, unit: " /s" } + ], + options: [ + { key: "position", label: "Position", type: "enum", fallback: "top", + values: [{ value: "top", label: "Top" }, { value: "center", label: "Centre" }, { value: "bottom", label: "Bottom" }] }, + { key: "color", label: "Color", type: "enum", fallback: "accent", + values: [{ value: "accent", label: "Accent" }, { value: "urgent", label: "Urgent" }, { value: "foreground", label: "Text" }] }, + { key: "text", label: "Text", type: "text", fallback: "", placeholder: "empty: {summary} and the body · also {app}, {rule}" } + ] + }, + { + type: "airplane", label: "Airplane", icon: "󰀝", + subtitle: "A plane tows the message across the screen", + rows: [ + { key: "duration", label: "Flight", min: 2, max: 20, step: 0.5, fallback: 7, unit: " s" }, + { key: "intensity", label: "Size", min: 0.5, max: 3, step: 0.1, fallback: 1, unit: "×" }, + { key: "altitude", label: "Altitude", min: 0.05, max: 0.95, step: 0.05, fallback: 0.2, unit: "" } + ], + options: [ + { key: "direction", label: "Direction", type: "enum", fallback: "ltr", + values: [{ value: "ltr", label: "Left to right" }, { value: "rtl", label: "Right to left" }] }, + { key: "text", label: "Text", type: "text", fallback: "", placeholder: "empty: {summary} · also {body}, {app}, {rule}" } + ] + }, + { + type: "confetti", label: "Confetti", icon: "", + subtitle: "Confetti pops up across the screen", + rows: [ + { key: "duration", label: "Duration", min: 0.5, max: 15, step: 0.5, fallback: 1, unit: " s" }, + { key: "intensity", label: "Amount", min: 0.2, max: 3, step: 0.1, fallback: 1, unit: "×" }, + { key: "speed", label: "Power", min: 0.3, max: 3, step: 0.1, fallback: 1, unit: "×" } + ], + options: [ + { key: "style", label: "Comes from", type: "enum", fallback: "cannons", + values: [{ value: "cannons", label: "Bottom corners, shot up" }, { value: "burst", label: "The centre, outwards" }, { value: "rain", label: "The top, falling" }] } + ] + }, + { + type: "blink", label: "Blink", icon: "󰌵", + subtitle: "The screen dims and comes back", + rows: [ + { key: "duration", label: "Duration", min: 0.2, max: 5, step: 0.1, fallback: 1, unit: " s" }, + { key: "intensity", label: "Darkness", min: 0.1, max: 1, step: 0.05, fallback: 0.6, unit: "" }, + { key: "speed", label: "Blinks", min: 1, max: 10, step: 0.5, fallback: 2, unit: " /s" } + ], + options: [] + }, + { + type: "sound", label: "Sound", icon: "󰕾", + subtitle: "Plays a chime", + rows: [ + { key: "intensity", label: "Volume", min: 0, max: 1, step: 0.05, fallback: 1, unit: "" }, + { key: "speed", label: "Repeat", min: 1, max: 5, step: 1, fallback: 1, unit: " ×" } + ], + options: [ + { key: "file", label: "File", type: "text", fallback: "", placeholder: "empty: the default chime · or a path to a sound file" } + ] + }, + { + type: "focus", label: "Focus app", icon: "", + subtitle: "Brings the app's window to the front", + rows: [], + options: [ + { key: "window", label: "Window", type: "text", fallback: "", placeholder: "empty: the app that sent it · or a window class or title" } + ] + }, + { + type: "command", label: "Command", icon: "󰆍", + subtitle: "Runs a command of yours", + rows: [], + options: [ + { key: "run", label: "Run", type: "text", fallback: "", placeholder: "empty: nothing runs · sees $AR_SUMMARY, $AR_BODY, $AR_APP, $AR_RULE" } + ] + } +] + +function find(type) { + for (var i = 0; i < EFFECTS.length; i++) if (EFFECTS[i].type === type) return EFFECTS[i] + return null +} + +function labelFor(type) { + var e = find(type) + return e ? e.label : String(type) +} + +function fallbackFor(type, key) { + var e = find(type) + if (!e) return undefined + for (var i = 0; i < e.rows.length; i++) if (e.rows[i].key === key) return e.rows[i].fallback + for (var j = 0; j < e.options.length; j++) if (e.options[j].key === key) return e.options[j].fallback + return undefined +} diff --git a/Flash.qml b/Flash.qml new file mode 100644 index 0000000..796dbd2 --- /dev/null +++ b/Flash.qml @@ -0,0 +1,131 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland +import qs.Commons + +// A glow that pulses in from the edges of every screen. Click-through, takes +// no keyboard focus, and reserves no space, so nothing under it moves. +// +// Options on the effect: +// duration seconds the glow keeps pulsing, default 1 +// intensity 0..1, how strong the glow gets, default 0.9 +// speed pulses per second, default 3 +// thickness px of glow from each edge, default 64 +// color a theme role ("accent" | "urgent" | "foreground") or any CSS color +Item { + id: root + width: 0 + height: 0 + visible: false + + property color glow: Color.accent + property int pulses: 3 + property int thickness: 64 + property int pulseMs: 333 + property real intensity: 0.9 + property real level: 0 + property bool active: false + + function resolveColor(value) { + var v = String(value || "").trim().toLowerCase() + if (!v || v === "accent") return Color.accent + if (v === "urgent") return Color.urgent + if (v === "foreground" || v === "text") return Color.foreground + if (v === "background") return Color.background + var c = Qt.color(value) + return c.valid === false ? Color.accent : c + } + + function number(value, fallback, min, max) { + var n = Number(value) + if (!isFinite(n)) return fallback + return Math.max(min, Math.min(max, n)) + } + + function trigger(opts) { + opts = opts || {} + glow = resolveColor(opts.color) + var duration = number(opts.duration, 1, 0.1, 30) + var speed = number(opts.speed, 3, 0.2, 20) + intensity = number(opts.intensity, 0.9, 0.05, 1) + thickness = Math.round(number(opts.thickness, 64, 4, 600)) + pulseMs = Math.round(1000 / speed) + pulses = Math.max(1, Math.round(duration * speed)) + anim.stop() + level = 0 + active = true + anim.loops = pulses + anim.start() + } + + SequentialAnimation { + id: anim + NumberAnimation { target: root; property: "level"; from: 0; to: 1; duration: root.pulseMs / 2; easing.type: Easing.OutQuad } + NumberAnimation { target: root; property: "level"; from: 1; to: 0; duration: root.pulseMs / 2; easing.type: Easing.InQuad } + onFinished: { + root.level = 0 + root.active = false + } + } + + Variants { + model: Quickshell.screens + + PanelWindow { + id: window + required property var modelData + screen: modelData + visible: root.active + color: "transparent" + anchors { top: true; bottom: true; left: true; right: true } + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "attention-required" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + mask: Region {} + + readonly property color solid: Qt.rgba(root.glow.r, root.glow.g, root.glow.b, 0.9) + readonly property color clear: Qt.rgba(root.glow.r, root.glow.g, root.glow.b, 0) + + Item { + anchors.fill: parent + opacity: root.level * root.intensity + + Rectangle { + anchors { top: parent.top; left: parent.left; right: parent.right } + height: root.thickness + gradient: Gradient { + GradientStop { position: 0; color: window.solid } + GradientStop { position: 1; color: window.clear } + } + } + Rectangle { + anchors { bottom: parent.bottom; left: parent.left; right: parent.right } + height: root.thickness + gradient: Gradient { + GradientStop { position: 0; color: window.clear } + GradientStop { position: 1; color: window.solid } + } + } + Rectangle { + anchors { top: parent.top; bottom: parent.bottom; left: parent.left } + width: root.thickness + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0; color: window.solid } + GradientStop { position: 1; color: window.clear } + } + } + Rectangle { + anchors { top: parent.top; bottom: parent.bottom; right: parent.right } + width: root.thickness + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0; color: window.clear } + GradientStop { position: 1; color: window.solid } + } + } + } + } + } +} diff --git a/FrameTicker.qml b/FrameTicker.qml new file mode 100644 index 0000000..b1da569 --- /dev/null +++ b/FrameTicker.qml @@ -0,0 +1,62 @@ +import QtQuick +import Quickshell +import Quickshell.Wayland + +// Keeps the compositor drawing a frame every refresh while a screen shader +// runs. Hyprland renders only when something on screen changed; a shader +// driven by time changes nothing by itself, so a nudge would move only when +// the cursor did. A 2px window per screen that repaints every frame is +// enough damage to keep the frames coming. +Item { + id: root + width: 0 + height: 0 + visible: false + + property bool active: false + property real tick: 0 + + function run(seconds) { + var ms = Math.round((Number(seconds) > 0 ? Number(seconds) : 1) * 1000) + 150 + stop.interval = Math.max(200, ms) + active = true + stop.restart() + } + + Timer { + id: stop + onTriggered: root.active = false + } + + NumberAnimation on tick { + running: root.active + from: 0 + to: 1 + duration: 1000 + loops: Animation.Infinite + } + + Variants { + model: Quickshell.screens + + PanelWindow { + required property var modelData + screen: modelData + visible: root.active + color: "transparent" + implicitWidth: 2 + implicitHeight: 2 + anchors { top: true; left: true } + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "attention-required-ticker" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + mask: Region {} + + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.02 + 0.03 * root.tick) + } + } + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d4bfcd1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 alanfortlink + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5197707 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# Attention Required + +Silence your notifications and let only the ones that matter through, loudly. +Rules watch every notification for words and apps; a match runs effects you +cannot miss: the MSN Messenger **nudge** that shakes the screen, a **flash**, +a **banner**, an **airplane** towing the message, **confetti**, a **blink**, a +**sound**, focusing the app, or a command of yours. While notifications are +silenced, only what a rule matches gets through, toast and all. + +![Attention Required](preview.png) + +> Video of the demo: coming soon. + +> Tested on **Omarchy 4** (Arch Linux, Hyprland with the Lua config, omarchy-shell). + +## Install + +```bash +omarchy plugin add https://github.com/alanfortlink/attention-required.git --enable +``` + +A bell appears in the bar. Click it for the settings, right-click it to pause +or resume every effect. A first rule is there to start: any notification that +mentions **deliveroo** shakes the screen. + +Runtime dependencies, all part of a stock Omarchy: `jq`, `busctl` (systemd), +`hyprctl`, `notify-send`, `pw-play` (for the sound). `./install.sh` is an +optional helper for a checkout somewhere else: it links the plugin into +`~/.config/omarchy/plugins`, puts the `attention-required` command on your +PATH and enables the bell. `omarchy plugin add` never runs it. + +## Settings + +Three pages behind the bell: + +1. **Rules**: one line each, on/off, click to open, plus button to add. At the + bottom: whether rules fire while notifications are silenced, and whether a + matched notification is shown anyway. +2. **One rule**: name; words as chips (`/regex/` works); any word or all words; + apps as chips, with suggestions from the apps that have sent notifications, + the ones running, and the ones installed; the effects; the cooldown. Every + field says what empty means (no words: any notification; no apps: any app). +3. **One effect of that rule**: its sliders and choices, a **Try** button, and + **Remove from this rule**. Click an effect on the rule page to turn it on + and land here. Every rule carries its own settings for each effect. + +Tab walks the fields in screen order, Esc goes back a page. In a chip field, +Enter or comma adds, Backspace on an empty entry removes the last chip, and +arrows pick a suggestion. + +## The rules file + +Everything lives in `~/.config/attention-required/rules.json`; the popup +writes it, hand edits reload on save, and it is the thing to keep in your +dotfiles (`attention-required export FILE` / `import FILE`). + +```json +{ + "version": 1, + "whileDnd": true, + "letThrough": true, + "rules": [ + { "name": "deliveries", "words": ["deliveroo", "/order #\\d+/"], "effects": ["nudge"], "cooldown": 5 }, + { "name": "boss", "words": ["urgent", "asap"], "apps": ["Slack"], "match": "all", + "effects": ["flash", { "type": "banner", "duration": 6, "position": "center" }, "sound"] } + ] +} +``` + +| Field | Meaning | +|---|---| +| `words` | Case-insensitive substrings of the title or body; `"/…/"` is a regex. Empty: any notification. | +| `match` | `"any"` (default) or `"all"`. | +| `apps` | Substrings of the sending app's name (`"chrome"` matches `Google Chrome`). Empty: any app. | +| `effects` | Names, or objects with a `type` and options. `[]` matches but does nothing. | +| `cooldown` | Seconds before the rule can fire again (3). | +| `enabled` | `false` keeps the rule without using it. | + +A top-level `"defaults": { "nudge": { "intensity": 6 } }` block applies to every rule +that does not set the option itself. + +## Effects + +| Effect | What it does | `duration` | `intensity` | `speed` | More | +|---|---|---|---|---|---| +| `nudge` | The screen shakes, phone-buzz style, through a Hyprland screen shader. `intensity` 6, `speed` 15 is the MSN nudge. | seconds (1) | how far, 0.5..30 (1.5) | positions per second (200) | | +| `flash` | A glow pulses in from the edges. | seconds (1) | 0..1 (0.9) | pulses per second (3) | `thickness` px (64), `color` | +| `banner` | The message drops in as a big card. | seconds it stays (3) | size (1) | slide speed (4) | `position`: top, center, bottom; `color`; `text` template | +| `airplane` | A plane bobs across the screen, trailing exhaust, towing the message on a rippling flag. | flight seconds (7) | size (1) | | `altitude` 0..1 (0.2), `direction`: ltr, rtl; `text` | +| `confetti` | Confetti in the theme's colours. | seconds (1) | amount (1) | launch power (1) | `style`: cannons (bottom corners, up), burst (centre), rain (top) | +| `blink` | The screen dims and comes back. | seconds (1) | darkness 0..1 (0.6) | blinks per second (2) | | +| `sound` | Plays a chime with `pw-play`. | | volume (1) | times (1) | `file` | +| `focus` | Brings the sending app's window to the front. | | | | `window`: a class or title instead | +| `command` | Runs a shell command. | | | | `run`; sees `AR_APP`, `AR_SUMMARY`, `AR_BODY`, `AR_RULE`, every option as `AR_OPT_` | + +`color` is `accent`, `urgent`, `foreground` or any CSS color. Text templates +take `{summary}`, `{body}`, `{app}`, `{rule}`. Your own effect is an executable +in `~/.config/attention-required/effects/` with the same environment. + +## Command line + +``` +attention-required list | add NAME --words a,b --apps x --effects nudge,flash | remove NAME +attention-required enable NAME | disable NAME | toggle | on | off | settings +attention-required test banner | test '{"type":"nudge","intensity":6,"speed":15}' +attention-required simulate "Google Chrome" "Your rider has arrived" "deliveroo.co.uk" +attention-required set nudge intensity 6 | status | export [FILE] | import FILE | edit +./demo.sh # a narrated tour of every effect, driven by real notifications +``` + +## What it runs and touches + +- Notifications are read from the session bus with `busctl monitor` (every + `Notify` call), so nothing is missed while silenced; there is no notification + daemon of its own and no network access. Messages over 256 KB are dropped and + fields are clipped before anything looks at them. +- A match while silenced is posted again as a critical `notify-send`, the one + kind Omarchy shows through Do Not Disturb; the plugin recognises its copy. +- The nudge sets Hyprland's `decoration.screen_shader` and turns + `debug.damage_tracking` off for the shake through `hyprctl eval`, restoring + both after; `focus` dispatches a window focus. Nothing under `~/.config/hypr` + is written. +- The `command` effect runs whatever `run` says, as you, with the notification + in the environment. Only import a rules file you trust. +- No sudo, no package installs, no downloads. State: `~/.config/attention-required/` + (rules, your effects), `~/.local/state/attention-required/paused`, + `$XDG_RUNTIME_DIR/attention-required/` (the shader). + +## Uninstall + +```bash +omarchy plugin remove alanfortlink.attention-required # or ./install.sh --uninstall +``` + +Your rules are left in `~/.config/attention-required`; delete that folder to +remove everything. + +## Development + +`node tests/rules.test.js` checks the matching. A saved QML file reloads in the +shell; `Service.qml` and `Rules.js` need `omarchy restart shell`. +`journalctl --user -f | grep attention-required` shows what it is doing. + +MIT license. diff --git a/Rules.js b/Rules.js new file mode 100644 index 0000000..cf36c39 --- /dev/null +++ b/Rules.js @@ -0,0 +1,209 @@ +// Rule parsing and matching. Pure functions, no QML, so the whole thing can +// be exercised from a terminal: `qmltestrunner` is not needed, `node` is +// enough (see tests/rules.test.js). +// Not a .pragma library: the shell caches those across plugin reloads. + +var DEFAULT_COOLDOWN = 3 +var DEFAULT_FIELDS = ["summary", "body"] +var DEFAULT_EFFECTS = [{ type: "nudge" }] + +function str(v) { + return v === undefined || v === null ? "" : String(v) +} + +function list(v) { + if (Array.isArray(v)) return v.map(str).filter(function(x) { return x.length > 0 }) + if (typeof v === "string" && v.length) return [v] + return [] +} + +// Notification bodies arrive as markup ("deliveroo.co.uk"). +// Matching is done on the words a person would read, not the tags. +function stripTags(s) { + return str(s) + .replace(/<[^>]*>/g, " ") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/\s+/g, " ") + .trim() +} + +// A word is a case-insensitive substring. Written as /.../ it is a regular +// expression instead, case-insensitive unless flags are given. +function compileWord(w) { + var m = /^\/(.+)\/([a-z]*)$/.exec(w) + if (m) { + var flags = m[2] || "i" + try { + return { regex: new RegExp(m[1], flags), source: w } + } catch (e) { + return { text: w.toLowerCase(), source: w, error: String(e) } + } + } + return { text: w.toLowerCase(), source: w } +} + +function normalizeEffect(e) { + if (typeof e === "string") { + var name = e.trim().toLowerCase() + return name ? { type: name } : null + } + if (e && typeof e === "object" && e.type) { + var out = {} + for (var k in e) out[k] = e[k] + out.type = str(e.type).trim().toLowerCase() + return out.type ? out : null + } + return null +} + +// No `effects` key at all means the nudge; an explicit empty list means +// the rule matches but nothing happens (a rule still being set up). +function normalizeEffects(raw) { + if (raw === undefined || raw === null) return DEFAULT_EFFECTS.map(function(d) { return { type: d.type } }) + var items = Array.isArray(raw) ? raw : [raw] + var out = [] + for (var i = 0; i < items.length; i++) { + var e = normalizeEffect(items[i]) + if (e) out.push(e) + } + return out +} + +function normalizeRule(raw, index) { + if (!raw || typeof raw !== "object") return null + var fields = list(raw.fields).map(function(f) { return f.toLowerCase() }) + var cooldown = Number(raw.cooldown) + return { + name: str(raw.name).trim() || ("rule-" + (index + 1)), + enabled: raw.enabled !== false, + words: list(raw.words).map(compileWord), + match: str(raw.match).toLowerCase() === "all" ? "all" : "any", + apps: list(raw.apps).map(function(a) { return a.toLowerCase() }), + fields: fields.length ? fields : DEFAULT_FIELDS.slice(), + effects: normalizeEffects(raw.effects), + cooldown: isFinite(cooldown) && cooldown >= 0 ? cooldown : DEFAULT_COOLDOWN + } +} + +// Top-level "defaults": options applied to every effect of that type unless +// the rule's own effect sets them. {"defaults": {"nudge": {"intensity": 10}}} +function normalizeDefaults(raw) { + var out = {} + if (!raw || typeof raw !== "object") return out + for (var type in raw) { + var opts = raw[type] + if (!opts || typeof opts !== "object") continue + var copy = {} + for (var k in opts) if (k !== "type") copy[k] = opts[k] + out[String(type).trim().toLowerCase()] = copy + } + return out +} + +// The effect as it will run: the defaults for its type under the options set +// on the rule. +function withDefaults(cfg, effect) { + if (!effect || !effect.type) return effect + var base = cfg && cfg.defaults ? cfg.defaults[effect.type] : null + if (!base) return effect + var out = {} + for (var d in base) out[d] = base[d] + for (var k in effect) out[k] = effect[k] + return out +} + +function normalizeConfig(raw) { + var cfg = { whileDnd: true, letThrough: true, defaults: {}, rules: [] } + if (!raw || typeof raw !== "object") return cfg + cfg.whileDnd = raw.whileDnd !== false + // While silenced, a notification a rule matched is posted again so its + // toast shows: silence everything, let the rules through. + cfg.letThrough = raw.letThrough !== false + cfg.defaults = normalizeDefaults(raw.defaults) + var rules = Array.isArray(raw.rules) ? raw.rules : [] + for (var i = 0; i < rules.length; i++) { + var r = normalizeRule(rules[i], i) + if (r) cfg.rules.push(r) + } + return cfg +} + +function textFor(rule, n) { + var parts = [] + for (var i = 0; i < rule.fields.length; i++) { + var f = rule.fields[i] + if (f === "summary" || f === "title") parts.push(str(n.summary)) + else if (f === "body") parts.push(stripTags(n.body)) + else if (f === "app") parts.push(str(n.app)) + } + return parts.join("\n") +} + +function appMatches(rule, app) { + if (!rule.apps.length) return true + var a = str(app).toLowerCase() + for (var i = 0; i < rule.apps.length; i++) + if (a.indexOf(rule.apps[i]) !== -1) return true + return false +} + +function wordMatches(w, text, lower) { + return w.regex ? w.regex.test(text) : lower.indexOf(w.text) !== -1 +} + +// Empty means anything: no words matches every notification (from the apps +// given, or from every app when those are empty too). +function ruleMatches(rule, n) { + if (!rule.enabled) return false + if (!appMatches(rule, n.app)) return false + if (!rule.words.length) return true + var text = textFor(rule, n) + var lower = text.toLowerCase() + for (var i = 0; i < rule.words.length; i++) { + var hit = wordMatches(rule.words[i], text, lower) + if (hit && rule.match === "any") return true + if (!hit && rule.match === "all") return false + } + return rule.match === "all" +} + +function matchingRules(cfg, n) { + var out = [] + for (var i = 0; i < cfg.rules.length; i++) + if (ruleMatches(cfg.rules[i], n)) out.push(cfg.rules[i]) + return out +} + +// What `state` and `rules` report over IPC: the compiled rule without the +// RegExp objects, which do not survive JSON.stringify. +function describeRule(rule) { + return { + name: rule.name, + enabled: rule.enabled, + words: rule.words.map(function(w) { return w.source }), + match: rule.match, + apps: rule.apps, + fields: rule.fields, + effects: rule.effects, + cooldown: rule.cooldown + } +} + +// "{summary}" style templates for effects that show text. An empty template +// is the summary. +function renderTemplate(tpl, notif, rule) { + var t = str(tpl).trim() + if (!t) t = "{summary}" + var map = { + app: str(notif ? notif.app : ""), + summary: str(notif ? notif.summary : ""), + body: stripTags(notif ? notif.body : ""), + rule: str(rule ? rule.name : "") + } + return t.replace(/\{(app|summary|body|rule)\}/g, function(m, k) { return map[k] }) +} diff --git a/Service.qml b/Service.qml new file mode 100644 index 0000000..14acae7 --- /dev/null +++ b/Service.qml @@ -0,0 +1,657 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import "Rules.js" as Rules + +// Headless service: streams every notification the shell puts on screen, +// runs it past the rules in ~/.config/attention-required/rules.json, and +// fires the effects of the rules that match. +// +// Notifications are read from the files Omarchy's notification service +// writes (one JSON per popup under ~/.local/state/omarchy/notifications/), +// which is the same contract the notification-center plugin relies on. It +// means no second notification daemon and no D-Bus eavesdropping. +Item { + id: root + width: 0 + height: 0 + visible: false + + property var shell: null + property var manifest: null + property var pluginRegistry: null + property string omarchyPath: "" + + readonly property string home: Quickshell.env("HOME") + readonly property string pluginDir: decodeURIComponent(String(Qt.resolvedUrl(".")).replace(/^file:\/\//, "").replace(/\/$/, "")) + readonly property string configDir: (Quickshell.env("XDG_CONFIG_HOME") || (home + "/.config")) + "/attention-required" + readonly property string rulesPath: configDir + "/rules.json" + + property var config: Rules.normalizeConfig(null) + property string configError: "" + property bool configLoaded: false + + // notification key -> { ruleName: true }. A popup file is written again + // when its sender updates it (Chromium fills the body in late), so the same + // notification is evaluated more than once but a rule fires once per key. + property var fired: ({}) + property var firedKeys: [] + property var lastFired: ({}) // rule name -> ms of last firing + property var recent: [] // last matches, newest first + property int seen: 0 + property int matched: 0 + + // Paused or armed. Kept as a flag file so it survives a shell restart, the + // way the notification service keeps Do Not Disturb. + readonly property string stateDir: (Quickshell.env("XDG_STATE_HOME") || (home + "/.local/state")) + "/attention-required" + readonly property string pausedFile: stateDir + "/paused" + property bool enabled: true + + function setEnabled(value) { + var on = value === true || value === "true" || value === "on" || value === "1" + root.enabled = on + Quickshell.execDetached(["bash", "-c", + "mkdir -p \"$1\"; if [ \"$2\" = on ]; then rm -f \"$3\"; else touch \"$3\"; fi", "--", + root.stateDir, on ? "on" : "off", root.pausedFile]) + root.log(on ? "armed" : "paused") + } + + Process { + id: readPaused + command: ["test", "-e", root.pausedFile] + running: true + onExited: function(code) { root.enabled = code !== 0 } + } + + function log(msg) { console.log("[attention-required] " + msg) } + + // Effects the shell draws itself. Everything else is a script in effects/. + Flash { id: flash } + Banner { id: banner } + Airplane { id: airplane } + Confetti { id: confetti } + Blink { id: blink } + FrameTicker { id: ticker } + readonly property var overlays: ({ flash: flash, banner: banner, airplane: airplane, confetti: confetti, blink: blink }) + + // ---------------------------------------------------------------- config + + Process { + id: ensureConfig + command: ["bash", "-c", + "mkdir -p \"$1/effects\"; [ -e \"$2\" ] || cp \"$3\" \"$2\"", "--", + root.configDir, root.rulesPath, root.pluginDir + "/rules.example.json"] + running: true + onExited: rulesFile.reload() + } + + FileView { + id: rulesFile + path: root.rulesPath + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: root.applyConfig(text()) + onLoadFailed: function(error) { + root.configError = "cannot read " + root.rulesPath + } + } + + // A save is often seen twice: once truncated, once complete. The first read + // fails to parse; rather than trust a second change event to arrive, read + // again shortly after. A file that is really broken just logs once more. + Timer { + id: rereadConfig + interval: 400 + onTriggered: rulesFile.reload() + } + + function applyConfig(text) { + var raw + try { + raw = JSON.parse(text) + } catch (e) { + var wasError = root.configError !== "" + root.configError = "rules.json is not valid JSON: " + e.message + if (!wasError) rereadConfig.restart() + root.log(root.configError) + return + } + root.configError = "" + // A write of ours is on its way to disk: what is in memory is newer than + // what was just read, so keep it. + if (saveTimer.running) return + root.rawConfig = raw + root.config = Rules.normalizeConfig(raw) + root.configLoaded = true + root.log("loaded " + root.config.rules.length + " rule(s) from " + root.rulesPath) + } + + // ---------------------------------------------------------------- editing + // + // The settings popup edits rules.json through these. rawConfig is the file + // as parsed, unknown fields included, so nothing a person wrote by hand is + // lost; every change replaces it (a fresh object, so bindings notice) and + // writes it back shortly after. + + property var rawConfig: ({ version: 1, rules: [] }) + readonly property var rawRules: Array.isArray(rawConfig.rules) ? rawConfig.rules : [] + + function cloneConfig() { + var c = JSON.parse(JSON.stringify(rawConfig || {})) + if (typeof c !== "object" || c === null || Array.isArray(c)) c = {} + if (!c.version) c.version = 1 + if (!Array.isArray(c.rules)) c.rules = [] + return c + } + + function commitConfig(next) { + root.rawConfig = next + root.config = Rules.normalizeConfig(next) + root.configError = "" + saveTimer.restart() + } + + function saveConfig() { + Quickshell.execDetached(["bash", "-c", + "mkdir -p \"$(dirname \"$2\")\" && printf '%s\\n' \"$1\" > \"$2\"", "--", + JSON.stringify(root.rawConfig, null, 2), root.rulesPath]) + } + + Timer { + id: saveTimer + interval: 300 + onTriggered: root.saveConfig() + } + + function setWhileDnd(value) { + var c = cloneConfig() + c.whileDnd = !!value + commitConfig(c) + } + + function setLetThrough(value) { + var c = cloneConfig() + c.letThrough = !!value + commitConfig(c) + } + + function defaultFor(type, key, fallback) { + var d = rawConfig && rawConfig.defaults ? rawConfig.defaults[type] : null + var v = d ? d[key] : undefined + return v === undefined || v === null || !isFinite(Number(v)) ? fallback : Number(v) + } + + function setDefault(type, key, value) { + var c = cloneConfig() + if (!c.defaults || typeof c.defaults !== "object" || Array.isArray(c.defaults)) c.defaults = {} + if (!c.defaults[type] || typeof c.defaults[type] !== "object") c.defaults[type] = {} + c.defaults[type][key] = value + commitConfig(c) + } + + function updateRule(index, patch) { + var c = cloneConfig() + if (!c.rules[index] || typeof c.rules[index] !== "object") return false + for (var k in patch) { + if (patch[k] === undefined) delete c.rules[index][k] + else c.rules[index][k] = patch[k] + } + commitConfig(c) + return true + } + + function addRule() { + var c = cloneConfig() + var n = c.rules.length + 1 + var name = "rule-" + n + while (c.rules.some(function(r) { return r && r.name === name })) name = "rule-" + (++n) + // No effects yet: the rule is set up in the popup, effect by effect. + c.rules.push({ name: name, words: [], apps: [], effects: [], cooldown: 3 }) + commitConfig(c) + return c.rules.length - 1 + } + + // ---- a rule's effects, each with its own options ---- + + function effectTypeOf(e) { + return typeof e === "string" ? e.trim().toLowerCase() : (e && e.type ? String(e.type).trim().toLowerCase() : "") + } + + function ruleEffectList(index) { + var rule = rawRules[index] + if (!rule) return [] + var raw = rule.effects + if (raw === undefined || raw === null) return ["nudge"] + return Array.isArray(raw) ? raw : [raw] + } + + function ruleHasEffect(index, type) { + var list = ruleEffectList(index) + for (var i = 0; i < list.length; i++) if (effectTypeOf(list[i]) === type) return true + return false + } + + function addRuleEffect(index, type) { + if (ruleHasEffect(index, type)) return + var list = ruleEffectList(index).slice() + list.push(String(type)) + updateRule(index, { effects: list }) + } + + function removeRuleEffect(index, type) { + var list = ruleEffectList(index).filter(function(e) { return effectTypeOf(e) !== type }) + updateRule(index, { effects: list }) + } + + // The option as it will run: set on the rule's effect, else the top-level + // defaults for that effect, else what the caller falls back to. + function ruleEffectOption(index, type, key, fallback) { + var list = ruleEffectList(index) + for (var i = 0; i < list.length; i++) { + var e = list[i] + if (effectTypeOf(e) !== type) continue + if (e && typeof e === "object" && e[key] !== undefined && e[key] !== null) return e[key] + } + var d = rawConfig && rawConfig.defaults ? rawConfig.defaults[type] : null + if (d && d[key] !== undefined && d[key] !== null) return d[key] + return fallback + } + + function setRuleEffectOption(index, type, key, value) { + var list = ruleEffectList(index).slice() + var found = false + for (var i = 0; i < list.length; i++) { + if (effectTypeOf(list[i]) !== type) continue + var obj = typeof list[i] === "object" && list[i] ? JSON.parse(JSON.stringify(list[i])) : { type: type } + obj.type = type + if (value === undefined || value === null || value === "") delete obj[key] + else obj[key] = value + // Back to the short form when nothing but the type is left. + var keys = Object.keys(obj) + list[i] = keys.length === 1 ? type : obj + found = true + } + if (!found) { + var fresh = { type: type } + if (value !== undefined && value !== null && value !== "") fresh[key] = value + list.push(Object.keys(fresh).length === 1 ? type : fresh) + } + updateRule(index, { effects: list }) + } + + function tryRuleEffect(index, type) { + var rule = root.config.rules[index] + if (!rule) return + var notif = { key: "test", source: "test", app: "attention-required", summary: "Test of " + rule.name, body: "A notification matched the rule “" + rule.name + "”.", urgency: 1 } + for (var i = 0; i < rule.effects.length; i++) + if (rule.effects[i].type === type) runEffect(rule.effects[i], notif, rule) + } + + function removeRule(index) { + var c = cloneConfig() + if (index < 0 || index >= c.rules.length) return false + c.rules.splice(index, 1) + commitConfig(c) + return true + } + + // ------------------------------------------------------------ app names + // + // Suggestions for a rule's apps, best first: names that actually arrived + // on notifications (that is the string a rule is matched against), then + // the apps running right now, then everything installed. + + property var notifiedApps: ({}) // app name -> count + property var runningApps: [] + property var appSuggestions: [] + + function noteApp(app) { + var name = Rules.str(app).trim() + if (!name || name === "notify-send" || name === "omarchy-action") return + var next = {} + for (var k in notifiedApps) next[k] = notifiedApps[k] + next[name] = (next[name] || 0) + 1 + notifiedApps = next + rebuildAppSuggestions() + } + + function entryName(desktopId) { + try { + var entry = DesktopEntries.heuristicLookup(desktopId) + if (entry && entry.name) return String(entry.name) + } catch (e) { + } + return "" + } + + function rebuildAppSuggestions() { + var seen = {} + var out = [] + function add(name, source) { + var key = String(name || "").trim() + if (!key || seen[key.toLowerCase()]) return + seen[key.toLowerCase()] = true + out.push({ name: key, source: source }) + } + var notified = Object.keys(notifiedApps).sort(function(a, b) { return notifiedApps[b] - notifiedApps[a] }) + for (var i = 0; i < notified.length; i++) add(notified[i], "notified") + for (var r = 0; r < runningApps.length; r++) add(runningApps[r], "running") + try { + var apps = DesktopEntries.applications.values + for (var a = 0; a < apps.length; a++) { + if (apps[a].noDisplay) continue + add(apps[a].name, "installed") + } + } catch (e) { + } + appSuggestions = out + } + + // Runs when the settings open: the running windows and the notifications + // already on disk. + function refreshApps() { + if (!appsProc.running) appsProc.running = true + } + + Process { + id: appsProc + command: ["bash", "-c", + "hyprctl -j clients 2>/dev/null | jq -r '.[].class' | sort -u | sed 's/^/class\\t/';" + + "d=\"${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/notifications\";" + + "cat \"$d\"/*.json \"$d\"/history/*.json 2>/dev/null | jq -r '.app // empty' | sort -u | sed 's/^/app\\t/'"] + stdout: StdioCollector { + onStreamFinished: { + var lines = text.split("\n") + var running = [] + var notified = {} + for (var k in root.notifiedApps) notified[k] = root.notifiedApps[k] + for (var i = 0; i < lines.length; i++) { + var parts = lines[i].split("\t") + if (parts.length < 2) continue + var value = parts.slice(1).join("\t").trim() + if (!value) continue + if (parts[0] === "class") { + var name = root.entryName(value) + if (!name) { + // A Chromium web app ("chrome-web.whatsapp.com__-Default") sends + // its notifications under the browser's name, so that is the + // name worth suggesting. + var pwa = /^(chrome|chromium|brave|msedge)-(.+?)__-/.exec(value) + if (pwa) { + var browsers = { chrome: "google-chrome", chromium: "chromium", brave: "brave-browser", msedge: "microsoft-edge" } + name = root.entryName(browsers[pwa[1]]) || pwa[2] + } else { + name = value + } + } + if (running.indexOf(name) === -1) running.push(name) + } else if (parts[0] === "app") { + if (value === "notify-send" || value === "omarchy-action") continue + if (!notified[value]) notified[value] = 1 + } + } + root.runningApps = running + root.notifiedApps = notified + root.rebuildAppSuggestions() + } + } + } + + Component.onCompleted: refreshApps() + + // Runs a rule's effects as if it had matched, for the settings popup. + function tryRule(index) { + var rule = root.config.rules[index] + if (!rule) return + var notif = { key: "test", source: "test", app: "attention-required", summary: "Test of " + rule.name, body: "A notification matched the rule “" + rule.name + "”.", urgency: 1 } + for (var i = 0; i < rule.effects.length; i++) runEffect(rule.effects[i], notif, rule) + } + + function testNotification(type) { + return { key: "test", source: "test", app: "attention-required", summary: "Attention required", + body: "This is what the " + String(type) + " effect looks like.", urgency: 1 } + } + + function tryEffect(type) { + runEffect({ type: String(type) }, testNotification(type), { name: "test" }) + } + + // --------------------------------------------------------------- watching + + Process { + id: watchProc + command: [root.pluginDir + "/bin/ar-watch"] + running: true + stdout: SplitParser { + onRead: function(line) { root.absorb(line) } + } + stderr: SplitParser { + onRead: function(line) { root.log("watch: " + line) } + } + onExited: function(code) { root.log("watcher exited with " + code) } + } + + // A watcher that died, or never started (the script missing for a moment + // while the plugin is updated), is brought back. Quickshell fires no + // `exited` for a process that failed to start, so this polls rather than + // reacting. + Timer { + interval: 5000 + running: true + repeat: true + onTriggered: if (!watchProc.running) watchProc.running = true + } + + function absorb(line) { + var n + try { + n = JSON.parse(line) + } catch (e) { + return + } + if (!n || !n.key) return + // A toast we posted ourselves to get a silenced match through: not a + // new notification, so it must not run the rules again. + if (root.isRepost(n)) return + root.seen++ + if (!root.enabled) return + if (n.source === "silenced" && !root.config.whileDnd) return + + var notif = { + key: String(n.key), + source: String(n.source || "popup"), + app: Rules.str(n.app), + summary: Rules.str(n.summary), + body: Rules.str(n.body), + urgency: n.urgency === undefined ? 1 : n.urgency + } + root.noteApp(notif.app) + var rules = Rules.matchingRules(root.config, notif) + if (!rules.length) return + + var now = Date.now() + var already = root.fired[notif.key] || {} + var isNew = !root.fired[notif.key] + for (var i = 0; i < rules.length; i++) { + var rule = rules[i] + if (already[rule.name]) continue + already[rule.name] = true + var last = root.lastFired[rule.name] || 0 + if (rule.cooldown > 0 && now - last < rule.cooldown * 1000) { + root.log("rule '" + rule.name + "' matched but is cooling down") + continue + } + root.lastFired[rule.name] = now + root.trigger(rule, notif) + if (notif.source === "silenced" && root.config.letThrough) root.repost(n) + } + root.fired[notif.key] = already + if (isNew) { + root.firedKeys.push(notif.key) + while (root.firedKeys.length > 200) delete root.fired[root.firedKeys.shift()] + } + } + + // ------------------------------------------------- letting matches through + // + // Do Not Disturb hides every toast. A notification a rule matched is the + // one you asked to see, so it is posted again the one way the shell shows + // while silenced: as a critical notification from notify-send. The copy is + // remembered so its own arrival is not taken for a new notification. + + property var reposted: ({}) // "summary\nbody" -> ms it was posted + + function repostKey(n) { + return Rules.str(n.summary) + "\n" + Rules.str(n.body) + } + + function isRepost(n) { + if (Rules.str(n.app) !== "notify-send" || Number(n.urgency) !== 2) return false + var stamp = root.reposted[repostKey(n)] + return !!stamp && Date.now() - stamp < 20000 + } + + function repost(n) { + var key = repostKey(n) + if (root.reposted[key] && Date.now() - root.reposted[key] < 5000) return + root.reposted[key] = Date.now() + var command = ["notify-send", "-u", "critical", "-t", "8000"] + var icon = Rules.str(n.appIcon).replace(/^file:\/\//, "") + if (icon.charAt(0) === "/") command.push("-i", icon) + var summary = Rules.str(n.summary) || Rules.str(n.app) || "Attention required" + command.push("--", summary, Rules.str(n.body)) + Quickshell.execDetached(command) + root.log("let through while silenced: " + summary) + } + + function trigger(rule, notif) { + root.matched++ + root.log("rule '" + rule.name + "' matched " + notif.app + ": " + notif.summary) + var entry = { time: Date.now(), rule: rule.name, app: notif.app, summary: notif.summary } + root.recent = [entry].concat(root.recent).slice(0, 20) + for (var i = 0; i < rule.effects.length; i++) + root.runEffect(rule.effects[i], notif, rule) + } + + // -------------------------------------------------------------- effects + + // `flash` is drawn by the shell itself (Flash.qml). Everything else is a + // script: ~/.config/attention-required/effects/ if you wrote one, + // otherwise effects/ shipped with the plugin. + function runEffect(effect, notif, rule) { + var type = String(effect.type || "") + if (!type) return + effect = Rules.withDefaults(root.config, effect) + if (root.overlays[type]) { + root.overlays[type].trigger(effect, notif, rule) + return + } + // The shake is a time-driven screen shader; keep frames coming while it runs. + if (type === "nudge") ticker.run(Number(effect.duration) > 0 ? Number(effect.duration) : 1) + var payload = { + effect: effect, + notification: { + key: notif.key, app: notif.app, summary: notif.summary, + body: Rules.stripTags(notif.body), urgency: notif.urgency + }, + rule: { name: rule.name } + } + Quickshell.execDetached([root.pluginDir + "/bin/ar-effect", type, JSON.stringify(payload)]) + } + + function parseEffect(spec) { + var text = String(spec || "").trim() + if (!text) return null + if (text.charAt(0) === "{") { + try { + return Rules.normalizeEffect(JSON.parse(text)) + } catch (e) { + return null + } + } + return Rules.normalizeEffect(text) + } + + // ------------------------------------------------------------------- ipc + + IpcHandler { + target: "attention-required" + + // attention-required test nudge + // attention-required test '{"type":"flash","color":"#ff0000"}' + function test(effect: string): string { + var e = root.parseEffect(effect) + if (!e) return "unknown effect: " + effect + root.runEffect(e, root.testNotification(e.type), { name: "test" }) + return "ran " + e.type + } + + // Runs a made-up notification past the rules, effects included. + function simulate(app: string, summary: string, body: string): string { + var key = "simulated-" + Date.now() + var notif = { key: key, app: app, summary: summary, body: body } + var names = Rules.matchingRules(root.config, notif).map(function(r) { return r.name }) + root.absorb(JSON.stringify({ key: key, source: "popup", app: app, summary: summary, body: body, urgency: 1 })) + if (!names.length) return "no rule matched" + return "matched: " + names.join(", ") + (root.enabled ? "" : " (paused, nothing ran)") + } + + function reload(): string { + rulesFile.reload() + return "reloading " + root.rulesPath + } + + // attention-required toggle | on | off + function toggle(): string { + root.setEnabled(!root.enabled) + return root.enabled ? "armed" : "paused" + } + + function setEnabled(value: string): string { + root.setEnabled(value) + return root.enabled ? "armed" : "paused" + } + + function isEnabled(): string { + return root.enabled ? "true" : "false" + } + + // attention-required set nudge intensity 6 + function setDefault(type: string, key: string, value: string): string { + var t = String(type).trim().toLowerCase(), k = String(key).trim() + if (!t || !k) return "usage: set