idle: added fade out before action to serve as a warning for the user and grace period

This commit is contained in:
Lemmy
2026-02-21 22:01:41 -05:00
parent cba199edab
commit cc50b59d63
7 changed files with 154 additions and 44 deletions
+3 -1
View File
@@ -1200,7 +1200,9 @@
"lock-label": "Lock screen",
"lock-description": "Minutes of inactivity before the lock screen activates.",
"suspend-label": "Suspend",
"suspend-description": "Minutes of inactivity before the system suspends."
"suspend-description": "Minutes of inactivity before the system suspends.",
"fade-duration-label": "Fade duration",
"fade-duration-description": "Seconds for the fade-to-black animation before each action fires. Any mouse movement cancels the fade."
},
"indicator": {
"default-value": "Default: {value}",
+8
View File
@@ -966,6 +966,14 @@
"tabLabel": "panels.idle.title",
"subTab": null
},
{
"labelKey": "panels.idle.fade-duration-label",
"descriptionKey": "panels.idle.fade-duration-description",
"widget": "NSpinBox",
"tab": 13,
"tabLabel": "panels.idle.title",
"subTab": null
},
{
"labelKey": "panels.launcher.settings-clipboard-history-label",
"descriptionKey": "panels.launcher.settings-clipboard-history-description",
+1
View File
@@ -725,6 +725,7 @@ Singleton {
property int screenOffTimeout: 0 // minutes, 0 = disabled
property int lockTimeout: 0 // minutes, 0 = disabled
property int suspendTimeout: 0 // minutes, 0 = disabled
property int fadeDuration: 5 // seconds of fade-to-black before action fires
}
// desktop widgets
+52
View File
@@ -0,0 +1,52 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Services.Power
/**
* IdleFadeOverlay — full-screen fade-to-black shown before each idle action.
*
* A single Loader wraps a Variants so per-screen windows only exist while
* a fade is in progress, keeping VRAM usage at zero at rest.
*
* Any mouse movement cancels the fade and unloads the windows immediately.
*/
Item {
id: root
Loader {
active: IdleService.fadePending !== ""
asynchronous: false
sourceComponent: Variants {
model: Quickshell.screens
delegate: PanelWindow {
id: overlay
required property ShellScreen modelData
screen: modelData
color: Qt.rgba(0, 0, 0, 0)
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "noctalia-fade-overlay"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
WlrLayershell.exclusionMode: ExclusionMode.Ignore
WlrLayershell.anchors {
top: true
bottom: true
left: true
right: true
}
ColorAnimation on color {
running: true
from: Qt.rgba(0, 0, 0, 0)
to: Qt.rgba(0, 0, 0, 1)
duration: IdleService.fadeDuration * 1000
easing.type: Easing.InQuad
}
}
}
}
}
@@ -91,5 +91,19 @@ ColumnLayout {
defaultValue: 0
onValueChanged: Settings.data.idle.suspendTimeout = value
}
NDivider {
Layout.fillWidth: true
}
NSpinBox {
label: I18n.tr("panels.idle.fade-duration-label")
description: I18n.tr("panels.idle.fade-duration-description")
from: 1
to: 60
value: Settings.data.idle.fadeDuration
defaultValue: Settings.getDefaultValue("idle.fadeDuration")
onValueChanged: Settings.data.idle.fadeDuration = value
}
}
}
+75 -43
View File
@@ -14,11 +14,13 @@ import qs.Services.UI
* 2. Lock screen — activates the session lock
* 3. Suspend — systemctl suspend
*
* IdleMonitor instances are created with Qt.createQmlObject() so the shell does
* not crash on compositors that lack the protocol.
* Each stage shows a fade-to-black overlay for a configurable grace period
* before executing the action. Any mouse movement cancels the fade.
*
* IdleMonitor instances are created with Qt.createQmlObject() so the shell
* does not crash on compositors that lack the protocol.
*
* Timeouts come from Settings.data.idle (in minutes). 0 = disabled.
*
* NOTE: IdleMonitor.timeout is in seconds.
*/
Singleton {
@@ -30,11 +32,15 @@ Singleton {
// Live idle time in seconds (updated by the 1s heartbeat monitor)
property int idleSeconds: 0
// Fade overlay state — "" means no fade in progress
property string fadePending: ""
readonly property int fadeDuration: Settings.data.idle.fadeDuration
property bool _monitorsCreated: false
property var _screenOffMonitor: null
property var _lockMonitor: null
property var _suspendMonitor: null
property var _heartbeatMonitor: null // 1s monitor for live idle tracking
property var _heartbeatMonitor: null
// Signals for external listeners (plugins, modules)
signal screenOffRequested
@@ -47,6 +53,61 @@ Singleton {
_applyTimeouts();
}
// Grace period timer — fires when fade completes without cancellation
Timer {
id: graceTimer
interval: root.fadeDuration * 1000
repeat: false
onTriggered: {
const action = root.fadePending;
root.fadePending = "";
root._executeAction(action);
}
}
// Counts up idleSeconds while the heartbeat monitor reports idle
Timer {
id: idleCounter
interval: 1000
repeat: true
onTriggered: root.idleSeconds++
}
// -------------------------------------------------------
function cancelFade() {
if (fadePending === "")
return;
Logger.i("IdleService", "Fade cancelled for:", fadePending);
fadePending = "";
graceTimer.stop();
}
function _onIdle(stage) {
// Don't re-trigger if already fading something
if (fadePending !== "")
return;
Logger.i("IdleService", "Idle fired:", stage);
fadePending = stage;
graceTimer.restart();
}
function _executeAction(stage) {
Logger.i("IdleService", "Executing action:", stage);
if (stage === "screenOff") {
CompositorService.turnOffMonitors();
root.screenOffRequested();
} else if (stage === "lock") {
if (PanelService.lockScreen && !PanelService.lockScreen.active) {
PanelService.lockScreen.active = true;
}
root.lockRequested();
} else if (stage === "suspend") {
CompositorService.suspend();
root.suspendRequested();
}
}
// -------------------------------------------------------
// Re-apply when settings change
Connections {
target: Settings
@@ -55,7 +116,6 @@ Singleton {
}
}
// Watch for timeout changes at runtime
Connections {
target: Settings.data.idle
function onScreenOffTimeoutChanged() {
@@ -72,15 +132,6 @@ Singleton {
}
}
// Counts up idleSeconds while the heartbeat monitor reports idle
Timer {
id: idleCounter
interval: 1000
repeat: true
onTriggered: root.idleSeconds++
}
// -------------------------------------------------------
function _applyTimeouts() {
const idle = Settings.data.idle;
const globalEnabled = idle.enabled;
@@ -91,10 +142,6 @@ Singleton {
_ensureHeartbeat();
}
/**
* Create, update, or destroy a stage IdleMonitor.
* timeoutSec: seconds (already converted from minutes by caller). 0 = disabled.
*/
function _setMonitor(stage, timeoutSec) {
const propName = "_" + stage + "Monitor";
const existing = root[propName];
@@ -109,11 +156,12 @@ Singleton {
}
if (existing) {
if (existing.timeout !== timeoutSec) {
existing.timeout = timeoutSec;
Logger.d("IdleService", stage + " monitor timeout updated to", timeoutSec, "s");
}
return;
if (existing.timeout === timeoutSec)
return;
// ext-idle-notify-v1 has no update-timeout request — must recreate
existing.destroy();
root[propName] = null;
Logger.d("IdleService", stage + " monitor timeout changed to", timeoutSec, "s, recreating");
}
try {
@@ -124,9 +172,10 @@ Singleton {
const monitor = Qt.createQmlObject(qml, root, "IdleMonitor_" + stage);
monitor.isIdleChanged.connect(function () {
if (monitor.isIdle) {
if (monitor.isIdle)
root._onIdle(stage);
}
else
root.cancelFade();
});
root[propName] = monitor;
root._monitorsCreated = true;
@@ -137,8 +186,6 @@ Singleton {
}
}
// 1-second heartbeat monitor for live idle time tracking.
// Always active so the settings panel can display current idle time.
function _ensureHeartbeat() {
if (_heartbeatMonitor)
return;
@@ -155,6 +202,7 @@ Singleton {
} else {
idleCounter.stop();
root.idleSeconds = 0;
root.cancelFade();
}
});
_heartbeatMonitor = monitor;
@@ -164,20 +212,4 @@ Singleton {
Logger.w("IdleService", "Heartbeat monitor failed:", e);
}
}
function _onIdle(stage) {
Logger.i("IdleService", "Idle fired:", stage);
if (stage === "screenOff") {
CompositorService.turnOffMonitors();
root.screenOffRequested();
} else if (stage === "lock") {
if (PanelService.lockScreen && !PanelService.lockScreen.active) {
PanelService.lockScreen.active = true;
}
root.lockRequested();
} else if (stage === "suspend") {
CompositorService.suspend();
root.suspendRequested();
}
}
}
+1
View File
@@ -146,6 +146,7 @@ ShellRoot {
}
LockScreen {}
FadeOverlay {}
// Settings window mode (single window across all monitors)
SettingsPanelWindow {}