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:
+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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user