169 lines
5.9 KiB
QML
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 }
|
|
}
|
|
}
|