// The visual bell: one flash at the edges of the screen when a notification // arrives that would have rung. // // For people who cannot hear the bell. It fires on exactly the notifications // services/Notifs.qml calls bell-eligible -- same per-application switch, same // low-urgency rule, same suppress-sound hint -- but NOT on the event-sounds // switch, which would make this do nothing for the person it is for. That rule // is pinned in Notifs.qml above `bellWouldRing`; this file only listens. // // Edges rather than the whole screen. A full-screen white flash is what X11's // visual bell did, and it is genuinely unpleasant: it destroys dark adaptation, // hides the thing you were reading at the moment it demands attention, and is // the shape of flash that photosensitivity guidance warns about. A soft glow // inward from the four edges is unmissable in peripheral vision and leaves the // middle of the screen -- the part being read -- alone. // // ONE animation per notification. There is no `loops`, no Timer that restarts // it, and a burst of notifications cannot stack flashes: while the animation is // running, further triggers are ignored outright. A strobing screen is a // seizure risk, not a notification. import Quickshell import Quickshell.Wayland import QtQuick import qs.config import qs.services PanelWindow { id: root property var modelData: null screen: root.modelData // Mapped only while flashing. The rest of the session this costs nothing, // and no surface sits over the desktop waiting for something to happen. property bool mapped: false visible: root.mapped anchors.top: true anchors.bottom: true anchors.left: true anchors.right: true // Reserve nothing and respect nothing: the glow is drawn over the whole // output including under the bar and the dock, which is what makes it // visible from wherever the eyes happen to be. exclusiveZone: 0 exclusionMode: ExclusionMode.Ignore color: "transparent" WlrLayershell.namespace: "qs-visual-bell" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: WlrKeyboardFocus.None // Entirely click-through: an empty mask means no part of this surface // takes a pointer event, so a flash cannot swallow the click you were in // the middle of making. mask: Region {} // How far the glow reaches in from each edge. Fixed pixels rather than a // share of the screen: this is about peripheral vision, which does not // scale with the size of the monitor. readonly property int reach: 72 // Deliberately NOT Theme.durFast / Theme.durNormal. Those collapse to zero // when Reduce motion is on, which would make the flash instantaneous and // therefore invisible -- switching on Reduce motion would silently switch // off Visual alerts. A flash is information, not decoration, so it keeps // its own timings. They are slow enough not to strobe and quick enough to // be over before it becomes irritating. readonly property int riseMs: 110 readonly property int fallMs: 340 Connections { target: Notifs function onBellEligible(notification: var): void { root.flash(); } } // The one-shot. A trigger arriving mid-flash is dropped rather than // queued or restarted, so ten notifications landing together are one // flash -- the same coalescing the audible bell gets from its throttle. function flash(): void { if (!Settings.visualAlerts || pulse.running) return; root.mapped = true; pulse.restart(); } Item { id: glow anchors.fill: parent opacity: 0 readonly property color tint: Theme.alpha(Theme.accent, 0.62) readonly property color fade: Theme.alpha(Theme.accent, 0) Rectangle { anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right height: root.reach gradient: Gradient { GradientStop { position: 0.0; color: glow.tint } GradientStop { position: 1.0; color: glow.fade } } } Rectangle { anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right height: root.reach gradient: Gradient { GradientStop { position: 0.0; color: glow.fade } GradientStop { position: 1.0; color: glow.tint } } } Rectangle { anchors.left: parent.left anchors.top: parent.top anchors.bottom: parent.bottom width: root.reach gradient: Gradient { orientation: Gradient.Horizontal GradientStop { position: 0.0; color: glow.tint } GradientStop { position: 1.0; color: glow.fade } } } Rectangle { anchors.right: parent.right anchors.top: parent.top anchors.bottom: parent.bottom width: root.reach gradient: Gradient { orientation: Gradient.Horizontal GradientStop { position: 0.0; color: glow.fade } GradientStop { position: 1.0; color: glow.tint } } } } // Up, down, gone. One sequence, run once per notification: no `loops`, no // repeat, and the window unmaps itself at the end so nothing is left over // the desktop between notifications. SequentialAnimation { id: pulse NumberAnimation { target: glow property: "opacity" from: 0 to: 1 duration: root.riseMs easing.type: Easing.OutCubic } NumberAnimation { target: glow property: "opacity" to: 0 duration: root.fallMs easing.type: Easing.InCubic } ScriptAction { script: root.mapped = false } } }