aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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
-rw-r--r--shared/Status.qml58
7 files changed, 201 insertions, 14 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
diff --git a/shared/Status.qml b/shared/Status.qml
index 8f80b9a..be090b3 100644
--- a/shared/Status.qml
+++ b/shared/Status.qml
@@ -32,34 +32,71 @@ Singleton {
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 was before presentation mode turned it on, so turning
- // presentation mode off restores it rather than clearing it. Held here
- // rather than in a file: it is meaningful only while presentation mode is
- // on, and presentation mode does not survive a reboot.
+ // 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 the dnd FileView has loaded, so the
- // recorded prior value depends on which file loads first.
+ // 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
@@ -67,10 +104,13 @@ Singleton {
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");
}
}
@@ -94,10 +134,11 @@ Singleton {
// 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.
+ // 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.presentation && root.inhibitWindow !== null
+ enabled: root.nolock && root.inhibitWindow !== null
}
component ModeFile: FileView {
@@ -136,4 +177,5 @@ Singleton {
ModeFile { id: dndFile; path: root.dir + "/status.dnd" }
ModeFile { id: presFile; path: root.dir + "/status.presentation" }
+ ModeFile { id: nolockFile; path: root.dir + "/status.nolock" }
}