commit 401b46c88639e84e5cd33ec53aaf4aafa9858840 Author: Alan Silva Date: Tue Sep 8 16:55:43 2026 +0100 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. 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