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:
@@ -0,0 +1,2 @@
|
||||
*.bak
|
||||
*.bak.*
|
||||
+235
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
> 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.
|
||||
@@ -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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
// A word is a case-insensitive substring. Written as /.../ it is a regular
|
||||
// expression instead, case-insensitive unless flags are given.
|
||||
function compileWord(w) {
|
||||
var m = /^\/(.+)\/([a-z]*)$/.exec(w)
|
||||
if (m) {
|
||||
var flags = m[2] || "i"
|
||||
try {
|
||||
return { regex: new RegExp(m[1], flags), source: w }
|
||||
} catch (e) {
|
||||
return { text: w.toLowerCase(), source: w, error: String(e) }
|
||||
}
|
||||
}
|
||||
return { text: w.toLowerCase(), source: w }
|
||||
}
|
||||
|
||||
function normalizeEffect(e) {
|
||||
if (typeof e === "string") {
|
||||
var name = e.trim().toLowerCase()
|
||||
return name ? { type: name } : null
|
||||
}
|
||||
if (e && typeof e === "object" && e.type) {
|
||||
var out = {}
|
||||
for (var k in e) out[k] = e[k]
|
||||
out.type = str(e.type).trim().toLowerCase()
|
||||
return out.type ? out : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// No `effects` key at all means the nudge; an explicit empty list means
|
||||
// the rule matches but nothing happens (a rule still being set up).
|
||||
function normalizeEffects(raw) {
|
||||
if (raw === undefined || raw === null) return DEFAULT_EFFECTS.map(function(d) { return { type: d.type } })
|
||||
var items = Array.isArray(raw) ? raw : [raw]
|
||||
var out = []
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var e = normalizeEffect(items[i])
|
||||
if (e) out.push(e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeRule(raw, index) {
|
||||
if (!raw || typeof raw !== "object") return null
|
||||
var fields = list(raw.fields).map(function(f) { return f.toLowerCase() })
|
||||
var cooldown = Number(raw.cooldown)
|
||||
return {
|
||||
name: str(raw.name).trim() || ("rule-" + (index + 1)),
|
||||
enabled: raw.enabled !== false,
|
||||
words: list(raw.words).map(compileWord),
|
||||
match: str(raw.match).toLowerCase() === "all" ? "all" : "any",
|
||||
apps: list(raw.apps).map(function(a) { return a.toLowerCase() }),
|
||||
fields: fields.length ? fields : DEFAULT_FIELDS.slice(),
|
||||
effects: normalizeEffects(raw.effects),
|
||||
cooldown: isFinite(cooldown) && cooldown >= 0 ? cooldown : DEFAULT_COOLDOWN
|
||||
}
|
||||
}
|
||||
|
||||
// Top-level "defaults": options applied to every effect of that type unless
|
||||
// the rule's own effect sets them. {"defaults": {"nudge": {"intensity": 10}}}
|
||||
function normalizeDefaults(raw) {
|
||||
var out = {}
|
||||
if (!raw || typeof raw !== "object") return out
|
||||
for (var type in raw) {
|
||||
var opts = raw[type]
|
||||
if (!opts || typeof opts !== "object") continue
|
||||
var copy = {}
|
||||
for (var k in opts) if (k !== "type") copy[k] = opts[k]
|
||||
out[String(type).trim().toLowerCase()] = copy
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The effect as it will run: the defaults for its type under the options set
|
||||
// on the rule.
|
||||
function withDefaults(cfg, effect) {
|
||||
if (!effect || !effect.type) return effect
|
||||
var base = cfg && cfg.defaults ? cfg.defaults[effect.type] : null
|
||||
if (!base) return effect
|
||||
var out = {}
|
||||
for (var d in base) out[d] = base[d]
|
||||
for (var k in effect) out[k] = effect[k]
|
||||
return out
|
||||
}
|
||||
|
||||
function normalizeConfig(raw) {
|
||||
var cfg = { whileDnd: true, letThrough: true, defaults: {}, rules: [] }
|
||||
if (!raw || typeof raw !== "object") return cfg
|
||||
cfg.whileDnd = raw.whileDnd !== false
|
||||
// While silenced, a notification a rule matched is posted again so its
|
||||
// toast shows: silence everything, let the rules through.
|
||||
cfg.letThrough = raw.letThrough !== false
|
||||
cfg.defaults = normalizeDefaults(raw.defaults)
|
||||
var rules = Array.isArray(raw.rules) ? raw.rules : []
|
||||
for (var i = 0; i < rules.length; i++) {
|
||||
var r = normalizeRule(rules[i], i)
|
||||
if (r) cfg.rules.push(r)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
function textFor(rule, n) {
|
||||
var parts = []
|
||||
for (var i = 0; i < rule.fields.length; i++) {
|
||||
var f = rule.fields[i]
|
||||
if (f === "summary" || f === "title") parts.push(str(n.summary))
|
||||
else if (f === "body") parts.push(stripTags(n.body))
|
||||
else if (f === "app") parts.push(str(n.app))
|
||||
}
|
||||
return parts.join("\n")
|
||||
}
|
||||
|
||||
function appMatches(rule, app) {
|
||||
if (!rule.apps.length) return true
|
||||
var a = str(app).toLowerCase()
|
||||
for (var i = 0; i < rule.apps.length; i++)
|
||||
if (a.indexOf(rule.apps[i]) !== -1) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function wordMatches(w, text, lower) {
|
||||
return w.regex ? w.regex.test(text) : lower.indexOf(w.text) !== -1
|
||||
}
|
||||
|
||||
// Empty means anything: no words matches every notification (from the apps
|
||||
// given, or from every app when those are empty too).
|
||||
function ruleMatches(rule, n) {
|
||||
if (!rule.enabled) return false
|
||||
if (!appMatches(rule, n.app)) return false
|
||||
if (!rule.words.length) return true
|
||||
var text = textFor(rule, n)
|
||||
var lower = text.toLowerCase()
|
||||
for (var i = 0; i < rule.words.length; i++) {
|
||||
var hit = wordMatches(rule.words[i], text, lower)
|
||||
if (hit && rule.match === "any") return true
|
||||
if (!hit && rule.match === "all") return false
|
||||
}
|
||||
return rule.match === "all"
|
||||
}
|
||||
|
||||
function matchingRules(cfg, n) {
|
||||
var out = []
|
||||
for (var i = 0; i < cfg.rules.length; i++)
|
||||
if (ruleMatches(cfg.rules[i], n)) out.push(cfg.rules[i])
|
||||
return out
|
||||
}
|
||||
|
||||
// What `state` and `rules` report over IPC: the compiled rule without the
|
||||
// RegExp objects, which do not survive JSON.stringify.
|
||||
function describeRule(rule) {
|
||||
return {
|
||||
name: rule.name,
|
||||
enabled: rule.enabled,
|
||||
words: rule.words.map(function(w) { return w.source }),
|
||||
match: rule.match,
|
||||
apps: rule.apps,
|
||||
fields: rule.fields,
|
||||
effects: rule.effects,
|
||||
cooldown: rule.cooldown
|
||||
}
|
||||
}
|
||||
|
||||
// "{summary}" style templates for effects that show text. An empty template
|
||||
// is the summary.
|
||||
function renderTemplate(tpl, notif, rule) {
|
||||
var t = str(tpl).trim()
|
||||
if (!t) t = "{summary}"
|
||||
var map = {
|
||||
app: str(notif ? notif.app : ""),
|
||||
summary: str(notif ? notif.summary : ""),
|
||||
body: stripTags(notif ? notif.body : ""),
|
||||
rule: str(rule ? rule.name : "")
|
||||
}
|
||||
return t.replace(/\{(app|summary|body|rule)\}/g, function(m, k) { return map[k] })
|
||||
}
|
||||
+657
@@ -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
File diff suppressed because it is too large
Load Diff
Executable
BIN
Binary file not shown.
Executable
+120
@@ -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
|
||||
Executable
+163
@@ -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
|
||||
@@ -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"
|
||||
Executable
+9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
@@ -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
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>&<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")
|
||||
Reference in New Issue
Block a user