1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
|
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 2 as
// published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
pragma Singleton
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
// Desktop modes as state. One file per mode under $XDG_RUNTIME_DIR, holding
// "0" or "1"; a missing file means off. The runtime directory is tmpfs, so a
// reboot resets every mode with no cleanup code here.
//
// The files are the interface, not this singleton: statusctl reads and writes
// them directly so it works while the shell is down, and the FileView watch
// means an external write repaints the drawer with no polling. It also means
// a mode set from outside still fires its effects, because the watch reaches
// the same handler a tile click would.
Singleton {
id: root
readonly property string dir: Quickshell.env("XDG_RUNTIME_DIR") || "/tmp"
readonly property bool dnd: dndFile.value
readonly property bool presentation: presFile.value
// Stored as "auto-lock disabled", so the missing-file default of off is
// the wanted default of locking on. The row inverts it for the label.
readonly property bool nolock: nolockFile.value
readonly property int activeCount: (root.dnd ? 1 : 0) + (root.presentation ? 1 : 0)
+ (root.nolock ? 1 : 0)
// Set once by shell.qml. IdleInhibitor does nothing with a null window,
// and the singleton has no window of its own to offer.
property var inhibitWindow: null
// What dnd and nolock were before presentation mode drove them, so turning
// presentation mode off restores them rather than clearing them. Held here
// rather than in a file: they are meaningful only while presentation mode
// is on, and presentation mode does not survive a reboot.
//
// Known limit: on a shell restart while presentation is already on,
// onPresentationChanged can run before these FileViews have loaded, so the
// recorded prior values depend on which file loads first.
property bool dndBeforePresentation: false
property bool nolockBeforePresentation: false
// Deadline for a timed disable, owned by the singleton rather than the row
// because the drawer page (and so the row) is destroyed on close while the
// effect has to outlive it. Shell-lifetime only: a restart while a timed
// disable is pending leaves the lock off until it is toggled, which is
// safe because the user opted into the disable.
property double nolockUntil: 0
function setMode(name, on) {
if (name === "dnd") {
dndFile.write(on);
} else if (name === "presentation") {
presFile.write(on);
} else if (name === "nolock") {
// Any write clears a pending deadline; the timed path sets it back.
root.nolockUntil = 0;
nolockFile.write(on);
}
}
// Disable auto-lock, optionally for a number of minutes. Zero or a
// non-number means until toggled back.
function disableLockFor(minutes) {
const m = Number(minutes);
root.setMode("nolock", true);
if (m > 0) root.nolockUntil = Date.now() + m * 60000;
}
function toggleMode(name) {
if (name === "dnd") root.setMode("dnd", !root.dnd);
else if (name === "presentation") root.setMode("presentation", !root.presentation);
else if (name === "nolock") root.setMode("nolock", !root.nolock);
}
// The timed disable re-enables itself. Runs only while a deadline is
// pending, so no tick burns while the mode is off or permanently on.
Timer {
id: nolockTimer
interval: 1000
repeat: true
running: root.nolockUntil > 0
onTriggered: {
if (Date.now() >= root.nolockUntil) root.setMode("nolock", false);
}
}
// Effects follow the mode rather than the setter, so a mode set by
// statusctl while the drawer is closed asserts them too.
onPresentationChanged: {
if (root.presentation) {
root.dndBeforePresentation = root.dnd;
root.nolockBeforePresentation = root.nolock;
root.setMode("dnd", true);
root.setMode("nolock", true);
root.runBreaktimer("pause");
} else {
root.setMode("dnd", root.dndBeforePresentation);
root.setMode("nolock", root.nolockBeforePresentation);
root.runBreaktimer("resume");
}
}
function runBreaktimer(verb) {
breakProc.command = ["breaktimer.sh", verb];
breakProc.running = false;
breakProc.running = true;
}
// breaktimer owns its own state file; this only calls its verbs. Two
// writers on that file would race with its daemon loop, which rewrites it
// on every phase change.
Process {
id: breakProc
onExited: code => {
if (code !== 0)
console.warn("status: breaktimer.sh " + breakProc.command[1] + " exited " + code);
}
}
// Wayland idle inhibit. The compositor advertises
// zwp_idle_inhibit_manager_v1 and hypridle honours it, so no D-Bus path
// is needed even though elogind runs here. nolock is the sole holder;
// presentation drives nolock, so it needs no second inhibitor.
IdleInhibitor {
window: root.inhibitWindow
enabled: root.nolock && root.inhibitWindow !== null
}
component ModeFile: FileView {
id: mf
property bool value: false
// FileView is documented to fire fileChanged on its own setText, so a
// write would re-enter this handler. Comparing before assigning makes
// that harmless: the reparse yields the value just written and the
// binding does not change.
function reparse() {
const t = mf.text().trim();
const v = (t === "1");
if (v !== mf.value) mf.value = v;
}
function write(on) {
const s = on ? "1\n" : "0\n";
mf.value = on;
mf.setText(s);
}
// Both are the documented defaults in 0.3.1, set explicitly because
// the CLI depends on them: statusctl watches close_write,moved_to
// precisely because an atomic write lands as a rename, so a future
// release flipping this default would break the watcher silently.
atomicWrites: true
watchChanges: true
printErrors: false
onFileChanged: mf.reload()
onLoaded: mf.reparse()
// A missing file is the off state, not an error worth logging.
onLoadFailed: mf.value = false
}
ModeFile { id: dndFile; path: root.dir + "/status.dnd" }
ModeFile { id: presFile; path: root.dir + "/status.presentation" }
ModeFile { id: nolockFile; path: root.dir + "/status.nolock" }
}
|