aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-18 12:26:48 +0200
committerDanilo M. <danix@danix.xyz>2026-09-18 12:26:48 +0200
commit25e33522a74d9ffb71e399d9060d9fdef42e792d (patch)
treecc950e645f1f2e83cb7e0f04d66f3317283f32d8 /desktop/modules
parente81c248c6243719f62b426dd68b0a74dc5b912d8 (diff)
downloadquickshell-25e33522a74d9ffb71e399d9060d9fdef42e792d.tar.gz
quickshell-25e33522a74d9ffb71e399d9060d9fdef42e792d.zip
feat(status): add a screen lock mode
dnd and presentation were the only modes, and presentation was the sole holder of the Wayland idle inhibitor. That inhibitor is the only thing that stops hypridle's loginctl lock-session at the 600s timeout, so screen locking is on by default and there was no way to turn it off on its own. nolock is stored as "auto-lock disabled" so the file keeps the registry's "missing means off" rule, and the row inverts it so the switch reads as the default: lock on. Presentation now drives nolock the same way it drives dnd, holding the prior value in memory and restoring it on exit, so it needs no second inhibitor and the inhibitor has a single holder. The page sets the disable permanently or for a number of minutes. The deadline and its timer live in the singleton rather than the row because the drawer page is destroyed on close while the effect has to outlive it. The timer is shell-lifetime, the same class of limit as dndBeforePresentation: a restart during a timed disable leaves the lock off until it is toggled, which is safe because the user opted into the disable. The mode governs idle-triggered locking only. The SUPER+l bind and the lock on suspend are separate paths and stay live.
Diffstat (limited to 'desktop/modules')
-rw-r--r--desktop/modules/status/LockRow.qml113
-rw-r--r--desktop/modules/status/README.md24
-rw-r--r--desktop/modules/status/StatusPage.qml10
-rw-r--r--desktop/modules/status/StatusTile.qml1
-rwxr-xr-xdesktop/modules/status/statusctl2
-rwxr-xr-xdesktop/modules/status/test-statusctl.sh7
6 files changed, 151 insertions, 6 deletions
diff --git a/desktop/modules/status/LockRow.qml b/desktop/modules/status/LockRow.qml
new file mode 100644
index 0000000..c59c2f1
--- /dev/null
+++ b/desktop/modules/status/LockRow.qml
@@ -0,0 +1,113 @@
+// 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.
+
+import QtQuick
+import QtQuick.Controls
+import "../.."
+
+// A switch plus an optional minutes field. The switch is the auto-lock state:
+// on means the screen locks on idle as usual. Turning it off asserts the idle
+// inhibitor, which is the only thing that stops hypridle. A number in the field
+// makes that disable temporary and it re-enables itself; an empty field makes
+// it permanent. The singleton owns the state and the timer, so the effect
+// outlives this row, which dies with the drawer page.
+Item {
+ id: row
+
+ // A tick so the row repaints when a timed disable expires on its own.
+ property double now: Date.now()
+ Timer {
+ interval: 1000
+ running: true
+ repeat: true
+ onTriggered: row.now = Date.now()
+ }
+
+ readonly property bool lockOn: !Status.nolock
+ readonly property bool timed: Status.nolockUntil > row.now
+
+ implicitHeight: Math.max(texts.implicitHeight, controls.implicitHeight) + 16
+
+ function hhmm(ts) {
+ const d = new Date(ts);
+ return ("0" + d.getHours()).slice(-2) + ":" + ("0" + d.getMinutes()).slice(-2);
+ }
+
+ function apply(lockOn) {
+ if (lockOn) {
+ Status.setMode("nolock", false);
+ } else {
+ const n = parseInt(minutes.text, 10);
+ Status.disableLockFor(isNaN(n) || n < 1 ? 0 : n);
+ }
+ }
+
+ Column {
+ id: texts
+ anchors {
+ left: parent.left
+ right: controls.left
+ rightMargin: 12
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 2
+
+ Text {
+ text: "Screen lock"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.text
+ }
+
+ Text {
+ width: parent.width
+ wrapMode: Text.WordWrap
+ text: row.lockOn ? "The screen locks automatically when the session is idle."
+ : row.timed ? "Auto-lock is off until " + row.hhmm(Status.nolockUntil) + "."
+ : "Auto-lock stays off until you turn it back on."
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ }
+ }
+
+ Row {
+ id: controls
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ spacing: 8
+
+ TextField {
+ id: minutes
+ width: 48
+ height: 28
+ placeholderText: "min"
+ horizontalAlignment: TextInput.AlignHCenter
+ color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ background: Rectangle {
+ radius: 6
+ color: Qt.alpha(Theme.surface, 0.9)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.15)
+ }
+ validator: IntValidator { bottom: 1; top: 1440 }
+ }
+
+ Switch {
+ id: sw
+ checked: row.lockOn
+ onToggled: row.apply(!row.lockOn)
+ }
+ }
+
+ // The shared Switch's click handler writes `checked` directly, which drops
+ // the declarative binding above. Resync on any state change so the timer,
+ // statusctl or presentation mode moves the switch back into agreement.
+ onLockOnChanged: sw.checked = row.lockOn
+}
diff --git a/desktop/modules/status/README.md b/desktop/modules/status/README.md
index 2092890..571f859 100644
--- a/desktop/modules/status/README.md
+++ b/desktop/modules/status/README.md
@@ -1,17 +1,22 @@
# status
-Desktop modes as state: `dnd` and `presentation`, owned by the `Status`
-singleton and stored as files under `$XDG_RUNTIME_DIR`.
+Desktop modes as state: `dnd`, `presentation` and `nolock`, owned by the
+`Status` singleton and stored as files under `$XDG_RUNTIME_DIR`.
## The files are the interface
$XDG_RUNTIME_DIR/status.dnd
$XDG_RUNTIME_DIR/status.presentation
+ $XDG_RUNTIME_DIR/status.nolock
Each holds `0` or `1`; a missing file means off. That directory is tmpfs, so a
reboot resets every mode and there is no cleanup code. A shell restart does
not: the files outlive the process and the singleton reads them back.
+`nolock` is stored as "auto-lock disabled", which is the inverse of the switch
+the page shows. The file keeps the registry's "missing means off" rule, and the
+page inverts it so the label reads as the default: screen lock on.
+
Anything can read a mode with `cat`. `statusctl` is the convenience, not the
mechanism, which is why it keeps working while quickshell is down.
@@ -35,9 +40,18 @@ and reasserted when it returns.
`dnd` has none of its own. It is state the notification daemon reads.
-`presentation` sets `dnd`, asserts a Wayland idle inhibitor, and pauses
-breaktimer. Turning it off restores `dnd` to the value it had before rather
-than clearing it, so hand-set DND survives a presentation.
+`presentation` sets `dnd` and `nolock`, asserts a Wayland idle inhibitor, and
+pauses breaktimer. Turning it off restores `dnd` and `nolock` to the values
+they had before rather than clearing them, so hand-set DND or a hand-set
+disable survives a presentation.
+
+`nolock` disables auto-lock by holding the idle inhibitor. That is the only
+thing that stops hypridle's `loginctl lock-session`, so the mode governs
+idle-triggered locking and nothing else: the `SUPER+l` bind and the lock on
+suspend are separate paths and stay live. The page can set it permanently or
+for a number of minutes, in which case the singleton's timer re-enables it.
+The timer is shell-lifetime: a restart during a timed disable leaves the lock
+off until it is toggled, the same class of limit as `dndBeforePresentation`.
breaktimer owns `$XDG_RUNTIME_DIR/breaktimer.state`. This module calls
`breaktimer.sh pause|resume` and never writes that file: its daemon loop
diff --git a/desktop/modules/status/StatusPage.qml b/desktop/modules/status/StatusPage.qml
index 2fbdc9b..470856d 100644
--- a/desktop/modules/status/StatusPage.qml
+++ b/desktop/modules/status/StatusPage.qml
@@ -47,6 +47,16 @@ Column {
color: Qt.alpha(Theme.text, 0.08)
}
+ LockRow {
+ width: page.width
+ }
+
+ Rectangle {
+ width: page.width
+ height: 1
+ color: Qt.alpha(Theme.text, 0.08)
+ }
+
SnoozeRow {
width: page.width
}
diff --git a/desktop/modules/status/StatusTile.qml b/desktop/modules/status/StatusTile.qml
index c4b87d4..ab3a7af 100644
--- a/desktop/modules/status/StatusTile.qml
+++ b/desktop/modules/status/StatusTile.qml
@@ -19,5 +19,6 @@ Text {
color: Status.activeCount > 0 ? Theme.text : Theme.subtext
text: Status.presentation ? "Presenting"
: Status.dnd ? "Do not disturb"
+ : Status.nolock ? "No lock"
: "All clear"
}
diff --git a/desktop/modules/status/statusctl b/desktop/modules/status/statusctl
index 8877ff3..571beef 100755
--- a/desktop/modules/status/statusctl
+++ b/desktop/modules/status/statusctl
@@ -25,7 +25,7 @@
set -u
-MODES="dnd presentation"
+MODES="dnd presentation nolock"
DIR="${XDG_RUNTIME_DIR:-/tmp}"
usage() {
diff --git a/desktop/modules/status/test-statusctl.sh b/desktop/modules/status/test-statusctl.sh
index 82e2de1..7b1b188 100755
--- a/desktop/modules/status/test-statusctl.sh
+++ b/desktop/modules/status/test-statusctl.sh
@@ -56,6 +56,13 @@ check "toggle from 0" "1" "$("$ctl" dnd get)"
"$ctl" dnd set 0
check "set 0 writes the file" "0" "$("$ctl" dnd get)"
+# nolock is a mode like any other: the mechanism is generic, this only pins
+# that the name is accepted by the mode list.
+"$ctl" nolock set 1
+check "nolock set 1" "1" "$("$ctl" nolock get)"
+"$ctl" nolock toggle
+check "nolock toggle" "0" "$("$ctl" nolock get)"
+
# An unknown mode is an error, not a silent success: a typo must not look
# like a mode that is off.
"$ctl" nosuch get >/dev/null 2>&1