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.
This commit is contained in:
2026-09-08 16:55:43 +01:00
commit 401b46c886
26 changed files with 3727 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
*.bak
*.bak.*
+235
View File
@@ -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
}
}
}
}
}
}
}
}
+145
View File
@@ -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
}
}
}
}
}
}
+72
View File
@@ -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
}
}
}
}
+169
View File
@@ -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
}
}
}
}
}
}
}
+131
View File
@@ -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
}
+131
View File
@@ -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 }
}
}
}
}
}
}
+62
View File
@@ -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)
}
}
}
}
+21
View File
@@ -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.
+144
View File
@@ -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_<NAME>` |
`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/<name>` 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.
+209
View File
@@ -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 ("<a href=...>deliveroo.co.uk</a>").
// Matching is done on the words a person would read, not the tags.
function stripTags(s) {
return str(s)
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&#39;/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] })
}
+657
View File
@@ -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/<type> if you wrote one,
// otherwise effects/<type> 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 <effect> <option> <value>"
var n = Number(value)
root.setDefault(t, k, isFinite(n) && String(value).trim() !== "" ? n : String(value))
return t + "." + k + " = " + String(value)
}
function rules(): string {
return JSON.stringify({
path: root.rulesPath,
error: root.configError,
whileDnd: root.config.whileDnd,
defaults: root.config.defaults,
rules: root.config.rules.map(Rules.describeRule)
})
}
function state(): string {
return JSON.stringify({
enabled: root.enabled,
rulesPath: root.rulesPath,
configLoaded: root.configLoaded,
configError: root.configError,
rules: root.config.rules.length,
watching: watchProc.running,
seen: root.seen,
matched: root.matched,
recent: root.recent,
appSuggestions: root.appSuggestions.length,
topApps: root.appSuggestions.slice(0, 8).map(function(s) { return s.name + " (" + s.source + ")" })
})
}
function ping(): string { return "ok" }
}
}
+1016
View File
File diff suppressed because it is too large Load Diff
Executable
BIN
View File
Binary file not shown.
Executable
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
#
# Streams every notification sent on this desktop, one JSON object per line:
# {"source": "popup" | "silenced", "key", "app", "summary", "body",
# "appIcon", "urgency", "replacesId", "timestamp"}
#
# The source of truth is the session bus: every notification is a Notify
# call on org.freedesktop.Notifications, watched with `busctl monitor`. That
# sees everything, including what Omarchy's notification service drops on
# the floor while Do Not Disturb is on (a bare notify-send, for one), so a
# rule can still let it through. "silenced" means Do Not Disturb was on when
# it arrived.
#
# Without busctl, the files Omarchy's notification service writes under
# ~/.local/state/omarchy/notifications/ are watched instead: one per popup,
# moved into history/ when it leaves the screen; a silenced one is written
# straight into history/.
set -uo pipefail
command -v jq >/dev/null 2>&1 || { echo "jq is not installed" >&2; exit 1; }
dnd_state() {
local state
state=$(omarchy-shell notifications isDnd 2>/dev/null || true)
[[ $state == on ]] && echo silenced || echo popup
}
# ----------------------------------------------------------------- D-Bus
if [[ ${AR_SOURCE:-bus} == bus ]] && command -v busctl >/dev/null 2>&1; then
exec 3< <(busctl --user monitor --json=short \
--match "type='method_call',interface='org.freedesktop.Notifications',member='Notify'" 2>/dev/null)
monitor=$!
trap 'kill "$monitor" 2>/dev/null' EXIT INT TERM
# Anything on the bus can send a notification, of any size. A message
# over 256 KB is dropped unread, and what is passed on is cut to what a
# rule could ever need: no field longer than a few thousand characters.
while IFS= read -r line <&3; do
[[ $line == *'"member":"Notify"'* ]] || continue
(( ${#line} > 262144 )) && continue
source=$(dnd_state)
jq -c --arg source "$source" '
def clip($n): if type == "string" then .[0:$n] else "" end;
select(.member == "Notify" and (.payload.data | type) == "array" and (.payload.data | length) >= 5)
| .payload.data as $d
| (if ($d[6] | type) == "object" then $d[6] else {} end) as $hints
| (if ($d[1] | type) == "number" then $d[1] else 0 end) as $replaces
| {
source: $source,
key: (if $replaces != 0 then "\($d[0] | clip(200)):replaces:\($replaces)" else "\(."timestamp-realtime" // 0)-\(.cookie // 0)" end),
app: ($d[0] | clip(200)),
summary: ($d[3] | clip(2000)),
body: ($d[4] | clip(8000)),
appIcon: (if ($d[2] | clip(1000)) != "" then ($d[2] | clip(1000)) else ($hints["image-path"].data // "" | clip(1000)) end),
urgency: ($hints.urgency.data // 1),
replacesId: $replaces,
timestamp: ((."timestamp-realtime" // 0) / 1000 | floor)
}' <<<"$line" 2>/dev/null
done
exit 0
fi
# ----------------------------------------------------------------- files
state_home=${XDG_STATE_HOME:-$HOME/.local/state}
popup_dir=${AR_SRC_DIR:-$state_home/omarchy/notifications}
history_dir=$popup_dir/history
mkdir -p "$popup_dir" "$history_dir" || exit 1
emit() {
local source=$1 path=$2 name key
name=$(basename "$path")
[[ $name == *.json ]] || return 0
key=${name%.json}
[[ -s $path ]] || return 0
# The service writes small files; anything bigger is not one of them.
(( $(stat -c %s "$path" 2>/dev/null || echo 0) > 262144 )) && return 0
jq -c --arg source "$source" --arg key "$key" '
def clip($n): if type == "string" then .[0:$n] else "" end;
{ source: $source, key: $key, app: (.app | clip(200)), summary: (.summary | clip(2000)),
body: (.body | clip(8000)), appIcon: (.appIcon | clip(1000)),
urgency: (if (.urgency | type) == "number" then .urgency else 1 end), timestamp: (.timestamp // 0) }' "$path" 2>/dev/null
}
if command -v inotifywait >/dev/null 2>&1; then
echo "busctl not found; watching the notification files instead" >&2
inotifywait -m -q -e close_write -e moved_to --format '%e|%w|%f' "$popup_dir" "$history_dir" |
while IFS='|' read -r events dir file; do
dir=${dir%/}
if [[ $dir == "$history_dir" ]]; then
[[ $events == *MOVED_TO* ]] && continue
emit silenced "$dir/$file"
else
emit popup "$dir/$file"
fi
done
exit 0
fi
echo "neither busctl nor inotifywait found; polling the notification files every half second" >&2
declare -A seen
for f in "$popup_dir"/*.json "$history_dir"/*.json; do
[[ -e $f ]] && seen[$f]=$(stat -c %Y "$f" 2>/dev/null || echo 0)
done
while sleep 0.5; do
for f in "$popup_dir"/*.json; do
[[ -e $f ]] || continue
m=$(stat -c %Y "$f" 2>/dev/null || echo 0)
[[ ${seen[$f]:-} == "$m" ]] && continue
seen[$f]=$m
emit popup "$f"
done
for f in "$history_dir"/*.json; do
[[ -e $f ]] || continue
[[ -n ${seen[$f]:-} ]] && continue
seen[$f]=$(stat -c %Y "$f" 2>/dev/null || echo 0)
[[ -n ${seen[$popup_dir/$(basename "$f")]:-} ]] && continue
emit silenced "$f"
done
done
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
#
# attention-required: rules that turn a notification into an effect.
#
# attention-required list the rules, as a table
# attention-required add NAME [options] add a rule
# --words a,b,c words to look for (any of them; /regex/ allowed)
# --apps x,y only notifications from these apps (substring of the app name; default: any app)
# --effects e1,e2 nudge | flash | sound | command | your own (default: nudge)
# --all every word must be present, not any
# --cooldown SECONDS at most one firing per rule in this many seconds (default 3)
# attention-required remove NAME
# attention-required enable NAME | disable NAME
# attention-required test EFFECT run one effect now, e.g. `test nudge`
# or `test '{"type":"flash","color":"urgent"}'`
# attention-required simulate APP SUMMARY [BODY] run a made-up notification past the rules
# attention-required settings open the settings popup (also: click the bell in the bar)
# attention-required set EFFECT OPTION VALUE change a default, e.g. `set nudge intensity 6`
# attention-required toggle | on | off pause or resume every effect (also: right-click the bell)
# attention-required status what the service has seen and fired
# attention-required reload re-read rules.json (it is also watched)
# attention-required edit open rules.json in $EDITOR
# attention-required path print where rules.json is
# attention-required export [FILE] copy the rules to FILE (or print them) to keep or share
# attention-required import FILE replace the rules with FILE's (the old file is kept as a .bak)
set -euo pipefail
config_dir=${XDG_CONFIG_HOME:-$HOME/.config}/attention-required
rules=$config_dir/rules.json
here=$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)
die() { echo "attention-required: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "$1 is not installed"; }
need jq
ipc() {
if command -v omarchy-shell >/dev/null 2>&1; then
omarchy-shell attention-required "$@"
else
quickshell ipc -p "${OMARCHY_PATH:-/usr/share/omarchy}/shell" call attention-required "$@"
fi
}
ipc_panel() {
if command -v omarchy-shell >/dev/null 2>&1; then
omarchy-shell alanfortlink.attention-required "$@"
else
quickshell ipc -p "${OMARCHY_PATH:-/usr/share/omarchy}/shell" call alanfortlink.attention-required "$@"
fi
}
ensure_rules() {
mkdir -p "$config_dir/effects"
[[ -e $rules ]] || cp "$here/rules.example.json" "$rules"
}
# Written in place rather than moved into place, so the shell's file watch
# sees the change without a restart.
write_rules() {
local tmp
tmp=$(mktemp "$config_dir/.rules.XXXXXX")
cat > "$tmp"
jq . "$tmp" >/dev/null || { rm -f "$tmp"; die "refusing to write invalid JSON"; }
cat "$tmp" > "$rules"
rm -f "$tmp"
}
split_list() { jq -cn --arg s "$1" '$s | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))'; }
have_rule() { jq -e --arg n "$1" '.rules // [] | any(.name == $n)' "$rules" >/dev/null; }
cmd_list() {
ensure_rules
jq -r '
(.rules // []) | if length == 0 then "no rules" else
(["NAME", "ON", "WORDS", "APPS", "EFFECTS", "COOLDOWN"] | @tsv),
(.[] | [
.name,
(if .enabled == false then "off" else "on" end),
((.words // []) | join(", ")),
((.apps // []) | if length == 0 then "any" else join(", ") end),
((.effects // ["nudge"]) | map(if type == "string" then . else .type end) | join(", ")),
(.cooldown // 3 | tostring)
] | @tsv) end' "$rules" | column -t -s $'\t'
}
cmd_add() {
local name=${1:-}; shift || true
[[ -n $name ]] || die "usage: add NAME --words a,b [--apps x,y] [--effects nudge,flash] [--all] [--cooldown N]"
local words='[]' apps='[]' effects='["nudge"]' match=any cooldown=3
while (($#)); do
case $1 in
--words) words=$(split_list "${2:-}"); shift 2 ;;
--apps) apps=$(split_list "${2:-}"); shift 2 ;;
--effects) effects=$(split_list "${2:-}"); shift 2 ;;
--all) match=all; shift ;;
--cooldown) cooldown=${2:-3}; shift 2 ;;
*) die "unknown option $1" ;;
esac
done
ensure_rules
have_rule "$name" && die "a rule named '$name' exists; remove it first"
jq --arg name "$name" --argjson words "$words" --argjson apps "$apps" --argjson effects "$effects" \
--arg match "$match" --argjson cooldown "$cooldown" '
.rules = ((.rules // []) + [{name: $name, words: $words, apps: $apps, effects: $effects, match: $match, cooldown: $cooldown}])
| .version = (.version // 1)' "$rules" | write_rules
echo "added '$name'"
ipc reload >/dev/null 2>&1 || true
}
cmd_remove() {
local name=${1:-}
[[ -n $name ]] || die "usage: remove NAME"
ensure_rules
have_rule "$name" || die "no rule named '$name'"
jq --arg n "$name" '.rules = ((.rules // []) | map(select(.name != $n)))' "$rules" | write_rules
echo "removed '$name'"
ipc reload >/dev/null 2>&1 || true
}
cmd_toggle() {
local enabled=$1 name=${2:-}
[[ -n $name ]] || die "usage: enable|disable NAME"
ensure_rules
have_rule "$name" || die "no rule named '$name'"
jq --arg n "$name" --argjson e "$enabled" '.rules = ((.rules // []) | map(if .name == $n then .enabled = $e else . end))' "$rules" | write_rules
echo "'$name' is now $([[ $enabled == true ]] && echo on || echo off)"
ipc reload >/dev/null 2>&1 || true
}
case ${1:-help} in
list|ls) cmd_list ;;
add) shift; cmd_add "$@" ;;
remove|rm) shift; cmd_remove "$@" ;;
enable) shift; cmd_toggle true "$@" ;;
disable) shift; cmd_toggle false "$@" ;;
test) [[ -n ${2:-} ]] || die "usage: test EFFECT"; ipc test "$2" ;;
simulate|sim) [[ -n ${3:-} ]] || die "usage: simulate APP SUMMARY [BODY]"; ipc simulate "$2" "$3" "${4:-}" ;;
settings) ipc_panel toggle ;;
set) [[ -n ${4:-} ]] || die "usage: set <effect> <option> <value> e.g. set nudge intensity 6"; ipc setDefault "$2" "$3" "$4" ;;
toggle) ipc toggle ;;
on|resume) ipc setEnabled true ;;
off|pause) ipc setEnabled false ;;
status) ipc state | jq . ;;
rules) ipc rules | jq . ;;
reload) ipc reload ;;
edit) ensure_rules; "${EDITOR:-nvim}" "$rules" ;;
path) echo "$rules" ;;
export)
ensure_rules
if [[ -n ${2:-} ]]; then cp "$rules" "$2" && echo "saved to $2"; else cat "$rules"; fi ;;
import)
[[ -n ${2:-} && -r $2 ]] || die "usage: import FILE"
jq -e '(.rules | type) == "array"' "$2" >/dev/null 2>&1 || die "$2 is not a rules file (needs a \"rules\" list)"
ensure_rules
backup="$rules.bak.$(date +%s)"
cp "$rules" "$backup"
jq '.version = (.version // 1)' "$2" | write_rules
echo "imported $2 ($(jq '.rules | length' "$rules") rules); the previous file is $backup"
ipc reload >/dev/null 2>&1 || true ;;
help|-h|--help) sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//' ;;
*) die "unknown command '$1' (try: attention-required help)" ;;
esac
Executable
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env bash
#
# A guided tour of every effect, one after the other, driven by real
# notifications. Each step sends a notification whose text matches a
# "<effect>-test" rule if you have one (attention-required add nudge-test
# --words nudge --effects nudge), and otherwise runs the effect directly.
#
# ./demo.sh every effect, then the "let through while silenced" trick
# ./demo.sh nudge banner airplane only these
# ./demo.sh --quick shorter pauses
set -uo pipefail
cli=$(command -v attention-required || echo "$(cd "$(dirname "$0")" && pwd)/bin/attention-required")
quick=0
picked=()
for arg in "$@"; do
case $arg in
--quick|-q) quick=1 ;;
-h|--help) sed -n '2,/^set -uo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;;
*) picked+=("$arg") ;;
esac
done
"$cli" status >/dev/null 2>&1 || { echo "the attention-required service is not running (is the plugin enabled?)" >&2; exit 1; }
# effect | seconds it takes | what to expect | message sent with it
steps=(
"nudge|2|the whole screen shakes for a second|Someone wants you. Now."
"flash|2|a glow pulses in from the edges|Look at the edges of the screen."
"blink|2|the screen dims and comes back|Lights out, and back."
"sound|2|a chime plays|That was the chime."
"banner|4|a big card with the message drops in from the top|Big enough to read from across the room."
"confetti|5|confetti is shot up from the bottom corners|Something to celebrate."
"airplane|9|a plane flies across, towing the message on a flag|Towed across the sky, just for you."
"focus|3|the window of the app that sent it comes to the front|Brings the app to the front (this one from the terminal)."
)
say() { printf '\n\033[1m%s\033[0m\n' "$*"; }
note() { printf ' %s\n' "$*"; }
pause() { sleep "$( (( quick )) && echo 1 || echo "$1")"; }
countdown() {
local n=$1
(( quick )) && n=1
while (( n > 0 )); do printf ' in %d…\r' "$n"; sleep 1; (( n-- )); done
printf ' \r'
}
wait_for() {
local n=$1
(( quick )) && n=1
note "waiting ${n}s for it to finish"
sleep "$n"
}
has_rule_for() {
"$cli" export 2>/dev/null | jq -e --arg w "$1" '.rules[] | select((.enabled // true) and ((.words // []) | map(ascii_downcase) | index($w)))' >/dev/null 2>&1
}
run_step() {
local effect=$1 hold=$2 expect=$3 message=$4
say "Next: $effect"
note "what to expect: $expect"
countdown 2
if has_rule_for "$effect"; then
note "sending: notify-send \"$effect\" \"$message\""
notify-send "$effect" "$message"
note "the rule with the word '$effect' picks it up"
else
note "no rule has the word '$effect', so running the effect directly: attention-required test $effect"
"$cli" test "$effect" >/dev/null
fi
wait_for "$hold"
}
wanted() {
(( ${#picked[@]} == 0 )) && return 0
local p
for p in "${picked[@]}"; do [[ $p == "$1" ]] && return 0; done
return 1
}
say "Attention Required: the tour"
echo "Every step is a plain notification; the rules do the rest. Ctrl+C stops."
pause 2
for step in "${steps[@]}"; do
IFS='|' read -r effect hold expect message <<<"$step"
wanted "$effect" && run_step "$effect" "$hold" "$expect" "$message"
done
if (( ${#picked[@]} == 0 )) && has_rule_for deliver; then
say "Next: silenced, but the rule gets through"
note "what to expect: one notification stays hidden, the next one shows anyway and runs its effects"
was=$(omarchy-shell notifications isDnd 2>/dev/null || echo off)
countdown 2
note "silencing notifications (the bell in the bar)"
omarchy-shell notifications setDnd true >/dev/null 2>&1
pause 1
note "sending: notify-send \"Silence\" \"nothing to see here\""
notify-send "Silence" "nothing to see here"
note "no rule matches it: nothing shows"
wait_for 3
note "sending: notify-send \"Rider update\" \"your order will deliver soon\""
notify-send "Rider update" "your order will deliver soon"
note "the rule with the word 'deliver' matches: its effects run and the toast is shown anyway"
wait_for 4
[[ $was == on ]] || omarchy-shell notifications setDnd false >/dev/null 2>&1
note "notifications back to how they were (silencing was $was)"
fi
say "That's the tour."
echo "Click the bell in the bar to change any of it: attention-required settings"
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
#
# Runs a shell command of yours, with the notification in the environment
# (AR_APP, AR_SUMMARY, AR_BODY, AR_RULE, ...; see bin/ar-effect).
#
# {"type": "command", "run": "notify-send \"$AR_RULE\" \"$AR_SUMMARY\""}
set -uo pipefail
[[ -n ${AR_OPT_RUN:-} ]] || { echo "command: the effect needs a \"run\" option" >&2; exit 2; }
exec bash -c "$AR_OPT_RUN"
Executable
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
#
# Brings the window of the app that sent the notification to the front.
#
# The notification only carries the app's name ("Google Chrome", "Telegram
# Desktop"); windows carry a class ("google-chrome", "org.telegram.desktop")
# and a title. Both are compared with everything but letters and digits
# stripped, so "Google Chrome" finds "google-chrome". The most recently
# focused matching window wins.
#
# Options (set on the effect in rules.json):
# window what to look for instead of the app's name (a class or a title)
set -uo pipefail
want=${AR_OPT_WINDOW:-${AR_APP:-}}
[[ -n $want ]] || exit 0
command -v hyprctl >/dev/null 2>&1 || exit 1
command -v jq >/dev/null 2>&1 || exit 1
find_window() {
hyprctl -j clients 2>/dev/null | jq -r --arg want "$1" '
def key: ascii_downcase | gsub("[^a-z0-9]"; "");
($want | key) as $w
| [ .[] | select($w != "" and (
((.class // "") | key | contains($w)) or
((.initialClass // "") | key | contains($w)) or
((.title // "") | key | contains($w)) or
((.initialTitle // "") | key | contains($w)))) ]
| sort_by(.focusHistoryID) | .[0].address // empty'
}
addr=$(find_window "$want")
# "Telegram Desktop" may only match as "telegram": try the first word too.
[[ -n $addr ]] || addr=$(find_window "${want%% *}")
[[ -n $addr ]] || { echo "focus: no window for '$want'" >&2; exit 0; }
# The address goes into a line of Lua for the compositor: only a hex address will do.
[[ $addr =~ ^0x[0-9a-fA-F]{1,16}$ ]] || { echo "focus: odd window address '$addr'" >&2; exit 1; }
# Lua-configured Hyprland takes the dispatcher through eval; older,
# conf-configured installs still take `hyprctl dispatch`.
hyprctl -q eval "hl.dispatch(hl.dsp.focus({ window = \"address:$addr\" }))" >/dev/null 2>&1 \
|| hyprctl -q dispatch focuswindow "address:$addr" >/dev/null 2>&1
Executable
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# The MSN Messenger nudge: the whole screen shakes for a moment.
#
# Done with a Hyprland screen shader that moves the whole picture to a new
# random offset `speed` times a second, applied for `duration` seconds and
# then taken away again. Damage tracking is switched off for those seconds
# so a frame is a full redraw; it is put back exactly as it was. (Hyprland
# still only draws when something changed: the service shows a 2px overlay
# that repaints every frame while this runs, see FrameTicker.qml.)
#
# Options (set on the effect in rules.json):
# duration seconds, default 1
# intensity 0.5..30, how far the picture moves, default 1.5 (a phone buzz; 6 is a proper MSN nudge)
# speed new positions per second, default 200: at or above the refresh rate every frame differs
set -uo pipefail
duration=${AR_OPT_DURATION:-1}
intensity=${AR_OPT_INTENSITY:-1.5}
speed=${AR_OPT_SPEED:-200}
num='^[0-9]*\.?[0-9]+$'
[[ $duration =~ $num ]] || duration=1
[[ $intensity =~ $num ]] || intensity=1.5
[[ $speed =~ $num ]] || speed=200
command -v hyprctl >/dev/null 2>&1 || { echo "nudge: hyprctl not found" >&2; exit 1; }
run_dir=${XDG_RUNTIME_DIR:-/tmp}/attention-required
mkdir -p "$run_dir" || exit 1
shader=$run_dir/nudge.frag
# One nudge at a time. A second one arriving mid-shake is dropped rather
# than queued: by the time it ran, the first would have made the point.
exec 9>"$run_dir/nudge.lock"
flock -n 9 || exit 0
amp=$(awk -v i="$intensity" 'BEGIN { printf "%.5f", (i > 30 ? 30 : i) / 1000 }')
rate=$(awk -v s="$speed" 'BEGIN { printf "%.1f", (s < 1 ? 1 : s) }')
cat > "$shader" <<FRAG
#version 320 es
precision highp float;
in vec2 v_texcoord;
uniform sampler2D tex;
uniform float time;
out vec4 fragColor;
// A new pseudo-random offset every 1/$rate s, so the picture jumps rather
// than glides: what a fast vibration looks like at any frame rate.
void main() {
float step = floor(mod(time, 1000.0) * $rate);
float r1 = fract(sin(step * 12.9898) * 43758.5453);
float r2 = fract(sin(step * 78.2330) * 43758.5453);
vec2 off = (vec2(r1, r2) - 0.5) * 2.0 * $amp;
fragColor = texture(tex, clamp(v_texcoord + off, vec2(0.0), vec2(1.0)));
}
FRAG
lua_string() {
local s=${1//\\/\\\\}
s=${s//\"/\\\"}
printf '%s' "$s"
}
# The config keys are written through Hyprland's Lua API (hl.config), which
# is what a Lua-configured Hyprland accepts at runtime; older, conf-configured
# installs still take `hyprctl keyword`.
set_option() {
local lua_section=$1 lua_key=$2 conf_key=$3 value=$4
if hyprctl -q eval "hl.config({ $lua_section = { $lua_key = $value } })" >/dev/null 2>&1; then
return 0
fi
local plain=$value
[[ $plain == \"*\" ]] && plain=${plain:1:-1}
hyprctl -q keyword "$conf_key" "$plain" >/dev/null 2>&1
}
prev_shader=$(hyprctl -j getoption decoration:screen_shader 2>/dev/null | jq -r '.str // empty')
[[ -z $prev_shader || $prev_shader == "$shader" ]] && prev_shader='[[EMPTY]]'
prev_damage=$(hyprctl -j getoption debug:damage_tracking 2>/dev/null | jq -r '.int // 2')
[[ $prev_damage =~ ^[0-9]+$ ]] || prev_damage=2
restore() {
set_option decoration screen_shader decoration:screen_shader "\"$(lua_string "$prev_shader")\""
set_option debug damage_tracking debug:damage_tracking "$prev_damage"
}
trap restore EXIT INT TERM
set_option debug damage_tracking debug:damage_tracking 0
set_option decoration screen_shader decoration:screen_shader "\"$(lua_string "$shader")\""
sleep "$duration"
Executable
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
#
# Plays a sound.
#
# Options (set on the effect in rules.json):
# file path to a sound file; default is the freedesktop "new message" chime
# intensity volume, 0.0 .. 1.0, default 1 (`volume` works too)
# speed how many times to play it, default 1 (`repeat` works too)
set -uo pipefail
file=${AR_OPT_FILE:-/usr/share/sounds/freedesktop/stereo/message-new-instant.oga}
volume=${AR_OPT_INTENSITY:-${AR_OPT_VOLUME:-1}}
times=${AR_OPT_SPEED:-${AR_OPT_REPEAT:-1}}
[[ $volume =~ ^[0-9]*\.?[0-9]+$ ]] || volume=1
[[ $times =~ ^[0-9]+$ ]] || times=1
file=${file/#\~/$HOME}
[[ -r $file ]] || { echo "sound: cannot read $file" >&2; exit 1; }
play() {
if command -v pw-play >/dev/null 2>&1; then
pw-play --volume="$volume" "$file"
elif command -v paplay >/dev/null 2>&1; then
paplay "$file"
elif command -v mpv >/dev/null 2>&1; then
mpv --no-video --really-quiet --volume="$(awk -v v="$volume" 'BEGIN { printf "%d", v * 100 }')" "$file"
else
echo "sound: no player found (pw-play, paplay or mpv)" >&2
return 1
fi
}
for ((i = 0; i < times; i++)); do
play || exit 1
done
Executable
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# Install the Attention Required plugin for the current user.
# ./install.sh link the plugin into ~/.config/omarchy/plugins (when run from a checkout elsewhere),
# put the `attention-required` CLI on PATH, enable the service
# ./install.sh --uninstall
# `omarchy plugin add <git-url> --enable` alone is enough for normal use; this script only adds conveniences.
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
ID=alanfortlink.attention-required
PLUGIN=$HOME/.config/omarchy/plugins/$ID
BIN=$HOME/.local/bin
MODE=${1:-}
if [[ $MODE == --uninstall ]]; then
omarchy plugin disable "$ID" >/dev/null 2>&1 || true
rm -f "$BIN/attention-required"
[[ -L $PLUGIN ]] && rm -f "$PLUGIN"
echo "uninstalled (your rules in ~/.config/attention-required were left alone)"
echo "If the plugin was added with 'omarchy plugin add', also run: omarchy plugin remove $ID"
exit 0
fi
missing=()
for c in jq hyprctl; do
command -v "$c" >/dev/null 2>&1 || missing+=("$c")
done
if ((${#missing[@]})); then
echo "error: missing commands: ${missing[*]}" >&2
exit 1
fi
if ! command -v inotifywait >/dev/null 2>&1; then
echo "note: inotifywait (inotify-tools) is not installed; notifications will be polled twice a second instead. 'omarchy pkg add inotify-tools' fixes that." >&2
fi
mkdir -p "$BIN" "$(dirname "$PLUGIN")"
chmod +x "$HERE"/bin/* "$HERE"/effects/*
ln -sfn "$HERE/bin/attention-required" "$BIN/attention-required"
if [[ $HERE != "$PLUGIN" ]]; then
if [[ -e $PLUGIN && ! -L $PLUGIN ]]; then
echo "error: $PLUGIN exists and is not a symlink; remove it first (omarchy plugin remove $ID)" >&2
exit 1
fi
ln -sfn "$HERE" "$PLUGIN"
fi
case ":$PATH:" in *":$BIN:"*) ;; *) echo "note: $BIN is not on your PATH; run the CLI as $BIN/attention-required" >&2 ;; esac
omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true
# A plugin with both a service and a bar widget is enabled by its bar entry
# alone; the bell goes next to the notification-silencing indicator.
omarchy bar put "$ID" --after omarchy.indicators >/dev/null 2>&1 \
|| omarchy plugin enable "$ID" --section center >/dev/null 2>&1 \
|| omarchy plugin enable "$ID" >/dev/null 2>&1 || true
echo "installed. Rules: ~/.config/attention-required/rules.json (a deliveroo rule is there to start)."
echo "The bell in the bar pauses and resumes the effects. Try one: attention-required test nudge"
+28
View File
@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"id": "alanfortlink.attention-required",
"name": "Attention Required",
"version": "0.2.0",
"author": "alanfortlink",
"license": "MIT",
"description": "Rules that watch every notification for words, from any app or only the apps you pick, and answer with an effect: an MSN-style nudge that shakes the screen, a flash around the edges, a sound, or a command of yours.",
"homepage": "https://github.com/alanfortlink/attention-required",
"kinds": [
"service",
"bar-widget"
],
"keepLoaded": true,
"entryPoints": {
"service": "Service.qml",
"barWidget": "Settings.qml"
},
"barWidget": {
"displayName": "Attention Required",
"description": "A bell that shows whether the effects are armed. Click it for the settings: rules, words, apps, and how strong each effect is. Right-click pauses or resumes.",
"category": "System",
"allowMultiple": false,
"defaultSection": "right",
"defaults": {},
"schema": []
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+18
View File
@@ -0,0 +1,18 @@
{
"version": 1,
"whileDnd": true,
"defaults": {
"nudge": { "duration": 1, "intensity": 1.5, "speed": 200 },
"flash": { "duration": 1, "intensity": 0.9, "speed": 3, "thickness": 64, "color": "accent" },
"sound": { "intensity": 1, "speed": 1 }
},
"rules": [
{
"name": "deliveroo",
"words": ["deliveroo"],
"apps": [],
"effects": ["nudge"],
"cooldown": 5
}
]
}
+61
View File
@@ -0,0 +1,61 @@
// node tests/rules.test.js
const fs = require("fs")
const path = require("path")
const assert = require("assert")
const src = fs.readFileSync(path.join(__dirname, "..", "Rules.js"), "utf8").replace(/\/\/ Not a .pragma library[^\n]*/, "")
const R = {}
new Function("exports", src + "\n" + ["normalizeConfig", "matchingRules", "stripTags", "describeRule", "normalizeEffect", "withDefaults", "renderTemplate"].map(n => `exports.${n} = ${n}`).join("\n"))(R)
const cfg = R.normalizeConfig({
rules: [
{ name: "deliveroo", words: ["deliveroo"] },
{ name: "boss", words: ["urgent", "asap"], apps: ["Slack"], effects: ["flash", { type: "nudge", duration: 2 }] },
{ name: "both", words: ["build", "failed"], match: "all" },
{ name: "re", words: ["/order #\\d+/"] },
{ name: "chrome-all", apps: ["chrome"] },
{ name: "everything", enabled: false },
{ name: "off", words: ["deliveroo"], enabled: false },
{ name: "app-field", words: ["telegram"], fields: ["app"] }
]
})
const names = n => R.matchingRules(cfg, n).map(r => r.name)
// The real one: Chrome, brand only in the body as a link.
assert.deepStrictEqual(names({ app: "Google Chrome", summary: "Your rider has arrived ✅", body: '<a href="https://deliveroo.co.uk/">deliveroo.co.uk</a>\n\nMeet your rider.' }), ["deliveroo", "chrome-all"])
assert.deepStrictEqual(names({ app: "Slack", summary: "URGENT: prod", body: "" }), ["boss"])
assert.deepStrictEqual(names({ app: "Discord", summary: "urgent", body: "" }), [])
assert.deepStrictEqual(names({ app: "CI", summary: "build failed", body: "" }), ["both"])
assert.deepStrictEqual(names({ app: "CI", summary: "build passed", body: "" }), [])
assert.deepStrictEqual(names({ app: "Mail", summary: "Order #1234 shipped", body: "" }), ["re"])
assert.deepStrictEqual(names({ app: "Telegram Desktop", summary: "hi", body: "" }), ["app-field"])
assert.deepStrictEqual(names({ app: "Mail", summary: "Deliveroo", body: "" }), ["deliveroo"])
assert.strictEqual(R.stripTags("<b>a</b>&amp;<img src=x>b"), "a & b")
assert.deepStrictEqual(cfg.rules[1].effects, [{ type: "flash" }, { type: "nudge", duration: 2 }])
assert.deepStrictEqual(cfg.rules[0].effects, [{ type: "nudge" }])
assert.strictEqual(cfg.rules[0].cooldown, 3)
assert.strictEqual(R.normalizeConfig({ rules: [{ name: "x", words: "one" }] }).rules[0].words[0].text, "one")
assert.deepStrictEqual(R.normalizeConfig(null).rules, [])
assert.ok(JSON.stringify(R.describeRule(cfg.rules[3])).includes("order #"))
const withD = R.normalizeConfig({ defaults: { Nudge: { intensity: 12, speed: 20 }, flash: { color: "urgent" } }, rules: [] })
assert.deepStrictEqual(R.withDefaults(withD, { type: "nudge", speed: 5 }), { type: "nudge", intensity: 12, speed: 5 })
assert.deepStrictEqual(R.withDefaults(withD, { type: "flash" }), { type: "flash", color: "urgent" })
assert.deepStrictEqual(R.withDefaults(withD, { type: "sound" }), { type: "sound" })
assert.deepStrictEqual(R.normalizeConfig({ defaults: "nope" }).defaults, {})
assert.strictEqual(R.normalizeConfig({}).whileDnd, true)
assert.strictEqual(R.normalizeConfig({ whileDnd: false }).whileDnd, false)
assert.strictEqual(R.normalizeConfig({}).letThrough, true)
assert.strictEqual(R.normalizeConfig({ letThrough: false }).letThrough, false)
const all = R.normalizeConfig({ rules: [{ name: "all" }, { name: "none", effects: [] }] })
assert.deepStrictEqual(R.matchingRules(all, { app: "X", summary: "y", body: "" }).map(r => r.name), ["all", "none"])
assert.deepStrictEqual(all.rules[0].effects, [{ type: "nudge" }])
assert.deepStrictEqual(all.rules[1].effects, [])
assert.strictEqual(R.renderTemplate("", { summary: "Hi", body: "<b>x</b>" }, { name: "r" }), "Hi")
assert.strictEqual(R.renderTemplate("{rule}: {summary} / {body} ({app})", { app: "A", summary: "Hi", body: "<b>x</b>" }, { name: "r" }), "r: Hi / x (A)")
console.log("rules: all tests passed")