Files

169 lines
5.9 KiB
QML

// 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 }
}
}