aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/status
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules/status')
-rw-r--r--desktop/modules/status/README.md59
-rw-r--r--desktop/modules/status/SnoozeRow.qml114
-rw-r--r--desktop/modules/status/StatusModule.qml39
-rw-r--r--desktop/modules/status/StatusPage.qml53
-rw-r--r--desktop/modules/status/StatusRow.qml61
-rw-r--r--desktop/modules/status/StatusTile.qml23
-rwxr-xr-xdesktop/modules/status/statusctl132
-rwxr-xr-xdesktop/modules/status/test-statusctl.sh87
8 files changed, 568 insertions, 0 deletions
diff --git a/desktop/modules/status/README.md b/desktop/modules/status/README.md
new file mode 100644
index 0000000..84736d5
--- /dev/null
+++ b/desktop/modules/status/README.md
@@ -0,0 +1,59 @@
+# status
+
+Desktop modes as state: `dnd` and `presentation`, 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
+
+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.
+
+Anything can read a mode with `cat`. `statusctl` is the convenience, not the
+mechanism, which is why it keeps working while quickshell is down.
+
+## statusctl
+
+ statusctl <mode> get prints 0 or 1
+ statusctl <mode> set 0|1
+ statusctl <mode> toggle
+ statusctl <mode> watch waybar JSON on every change
+
+The repo copy is the source; the user installs it to `~/bin`. `watch` watches
+the directory rather than the file, because an atomic write replaces the file
+and a watch on the old inode dies with it.
+
+Setting a mode with `statusctl` records the state without firing its effects.
+The shell sees the change through its own `FileView` watch and asserts them,
+so the effects follow either way. If the shell is down, the state is recorded
+and reasserted when it returns.
+
+## Effects
+
+`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.
+
+breaktimer owns `$XDG_RUNTIME_DIR/breaktimer.state`. This module calls
+`breaktimer.sh pause|resume` and never writes that file: its daemon loop
+rewrites it on every phase change, and two writers would race.
+
+## Waybar
+
+`custom/presentation` reads `statusctl presentation watch`. It replaces
+waybar's built-in `idle_inhibitor`, which cannot be kept alongside it: that
+module owns its own inhibitor object, so both would have to be released
+before the screen could lock.
+
+## The check
+
+ ./test-statusctl.sh
+
+Points `XDG_RUNTIME_DIR` at a temporary directory, so it never touches live
+modes. Covers the file format, the atomic write, the toggle, the unknown-mode
+error, both the activated report and the absent-file down report.
diff --git a/desktop/modules/status/SnoozeRow.qml b/desktop/modules/status/SnoozeRow.qml
new file mode 100644
index 0000000..046d7cb
--- /dev/null
+++ b/desktop/modules/status/SnoozeRow.qml
@@ -0,0 +1,114 @@
+// 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 Quickshell
+import Quickshell.Io
+import QtQuick
+import QtQuick.Controls
+import "../.."
+
+// A switch plus a minutes field. On snoozes for the typed minutes through
+// notify-snooze.sh; off clears. The last used value is persisted by the
+// script, so the field prefills from it.
+Item {
+ id: row
+
+ // A tick so the switch reflects the snooze ending on its own.
+ property double now: Date.now()
+ Timer {
+ interval: 1000
+ running: true
+ repeat: true
+ onTriggered: row.now = Date.now()
+ }
+
+ readonly property bool active: Notify.snoozeUntil > row.now
+
+ implicitHeight: Math.max(texts.implicitHeight, sw.implicitHeight) + 16
+
+ function apply(on) {
+ if (on) {
+ const n = parseInt(minutes.text, 10);
+ snooze.command = ["notify-snooze.sh", String(isNaN(n) || n < 1 ? 30 : n)];
+ } else {
+ snooze.command = ["notify-snooze.sh", "off"];
+ }
+ snooze.running = false;
+ snooze.running = true;
+ }
+
+ Process { id: snooze }
+
+ FileView {
+ id: lastUsed
+ path: `${Quickshell.env("HOME")}/.local/state/notify-snooze.minutes`
+ printErrors: false
+ onLoaded: minutes.text = text().trim()
+ onLoadFailed: minutes.text = "30"
+ }
+
+ Column {
+ id: texts
+ anchors {
+ left: parent.left
+ right: controls.left
+ rightMargin: 12
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 2
+
+ Text {
+ text: "Snooze"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.text
+ }
+
+ Text {
+ width: parent.width
+ wrapMode: Text.WordWrap
+ text: row.active ? "All notification balloons are held until snooze ends."
+ : "Hold every notification balloon for a number of minutes."
+ 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
+ text: "30"
+ 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.active
+ onToggled: row.apply(!row.active)
+ }
+ }
+
+ onNowChanged: sw.checked = row.active
+}
diff --git a/desktop/modules/status/StatusModule.qml b/desktop/modules/status/StatusModule.qml
new file mode 100644
index 0000000..30f8557
--- /dev/null
+++ b/desktop/modules/status/StatusModule.qml
@@ -0,0 +1,39 @@
+// 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 "../.."
+
+// Always active: the singleton holds the modes and their effects, and those
+// have to be asserted whether or not anyone has opened the drawer. The module
+// itself is thin, a tile and a page over Status.
+Module {
+ id: mod
+
+ name: "status"
+ label: "Status"
+ alwaysActive: true
+
+ // A toggle glyph, present in Inconsolata Nerd Font.
+ icon: "\uf205"
+
+ // The tile renders in its accent while any mode is on.
+ active: Status.activeCount > 0
+
+ tileContent: Component { StatusTile {} }
+
+ page: Component {
+ Page {
+ title: "Status"
+ StatusPage { width: parent.width }
+ }
+ }
+}
diff --git a/desktop/modules/status/StatusPage.qml b/desktop/modules/status/StatusPage.qml
new file mode 100644
index 0000000..2fbdc9b
--- /dev/null
+++ b/desktop/modules/status/StatusPage.qml
@@ -0,0 +1,53 @@
+// 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 "../.."
+
+// One row per mode. Adding a mode is one file in the singleton and one row
+// here, which is the point of a registry rather than two toggles.
+Column {
+ id: page
+
+ spacing: 4
+
+ StatusRow {
+ width: page.width
+ mode: "dnd"
+ label: "Do not disturb"
+ description: "Silences notification popups. Critical ones still appear."
+ value: Status.dnd
+ }
+
+ Rectangle {
+ width: page.width
+ height: 1
+ color: Qt.alpha(Theme.text, 0.08)
+ }
+
+ StatusRow {
+ width: page.width
+ mode: "presentation"
+ label: "Presentation"
+ description: "Do not disturb, no screen lock, breaktimer paused."
+ value: Status.presentation
+ }
+
+ Rectangle {
+ width: page.width
+ height: 1
+ color: Qt.alpha(Theme.text, 0.08)
+ }
+
+ SnoozeRow {
+ width: page.width
+ }
+}
diff --git a/desktop/modules/status/StatusRow.qml b/desktop/modules/status/StatusRow.qml
new file mode 100644
index 0000000..c9e78c0
--- /dev/null
+++ b/desktop/modules/status/StatusRow.qml
@@ -0,0 +1,61 @@
+// 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 "../.."
+
+// One mode: a label, a line saying what it does, and a switch.
+Item {
+ id: row
+
+ required property string mode
+ required property string label
+ required property string description
+ required property bool value
+
+ implicitHeight: Math.max(texts.implicitHeight, sw.implicitHeight) + 16
+
+ Column {
+ id: texts
+ anchors {
+ left: parent.left
+ right: sw.left; rightMargin: 12
+ verticalCenter: parent.verticalCenter
+ }
+ spacing: 2
+
+ Text {
+ text: row.label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2; bold: true }
+ color: Theme.text
+ }
+
+ Text {
+ width: parent.width
+ wrapMode: Text.WordWrap
+ text: row.description
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ }
+ }
+
+ Switch {
+ id: sw
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ checked: row.value
+ onToggled: Status.toggleMode(row.mode)
+ }
+
+ // The shared Switch's click handler writes `checked` directly, which drops
+ // the declarative binding above. Resync on any value change so an external
+ // write (statusctl, waybar) moves the switch back into agreement.
+ onValueChanged: sw.checked = row.value
+}
diff --git a/desktop/modules/status/StatusTile.qml b/desktop/modules/status/StatusTile.qml
new file mode 100644
index 0000000..c4b87d4
--- /dev/null
+++ b/desktop/modules/status/StatusTile.qml
@@ -0,0 +1,23 @@
+// 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 "../.."
+
+Text {
+ width: parent ? parent.width : implicitWidth
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Status.activeCount > 0 ? Theme.text : Theme.subtext
+ text: Status.presentation ? "Presenting"
+ : Status.dnd ? "Do not disturb"
+ : "All clear"
+}
diff --git a/desktop/modules/status/statusctl b/desktop/modules/status/statusctl
new file mode 100755
index 0000000..6fbb689
--- /dev/null
+++ b/desktop/modules/status/statusctl
@@ -0,0 +1,132 @@
+#!/bin/bash
+#
+# 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.
+#
+# Read, set and watch desktop modes. The modes are files under
+# XDG_RUNTIME_DIR holding 0 or 1; a missing file means off.
+#
+# This talks to the files, not to the shell, so it works while quickshell is
+# down. Setting a mode that way records the state without firing its effects;
+# the shell sees the change through its own watch and reasserts them.
+#
+# statusctl <mode> get prints 0 or 1
+# statusctl <mode> set 0|1
+# statusctl <mode> toggle
+# statusctl <mode> watch waybar JSON on every change
+
+set -u
+
+MODES="dnd presentation"
+DIR="${XDG_RUNTIME_DIR:-/tmp}"
+
+usage() {
+ printf 'usage: %s <%s> <get|set 0|1|toggle|watch>\n' \
+ "${0##*/}" "$(printf '%s' "$MODES" | tr ' ' '|')" >&2
+ exit 1
+}
+
+[[ $# -ge 2 ]] || usage
+
+mode="$1"
+action="$2"
+
+# A typo must fail loudly rather than read as a mode that happens to be off.
+case " $MODES " in
+ *" $mode "*) ;;
+ *) printf '%s: unknown mode: %s\n' "${0##*/}" "$mode" >&2; exit 1 ;;
+esac
+
+file="$DIR/status.$mode"
+
+read_mode() {
+ local v=""
+ # A missing file is the normal state before anything has written one and
+ # reads as off. Guarding on existence keeps the shell's redirection error
+ # off stderr: `2>/dev/null` on the command cannot suppress a failure of
+ # its own input redirect.
+ [[ -e "$file" ]] && v="$(tr -d '[:space:]' < "$file" 2>/dev/null)"
+ [[ "$v" == "1" ]] && printf '1' || printf '0'
+}
+
+# Write through a temporary file and rename, so no reader ever sees a
+# half-written value. This is also what FileView does on the QML side, and it
+# is why a watcher has to listen for moved_to as well as close_write.
+write_mode() {
+ local want="$1" tmp
+ tmp="$(mktemp "$DIR/.status.$mode.XXXXXX")" || exit 1
+ printf '%s\n' "$want" > "$tmp"
+ # The temp file is made in the same directory as the target, so this is a
+ # rename rather than a copy, and therefore atomic. A failure here has to
+ # be loud: reporting success on a write that did not land would leave the
+ # caller and the shell disagreeing about the mode, with an orphan temp
+ # file as the only trace.
+ mv -f "$tmp" "$file" || { rm -f "$tmp"; exit 1; }
+}
+
+emit() {
+ local state="$1"
+ printf '{"text": "", "alt": "%s", "class": "%s", "tooltip": "%s"}\n' \
+ "$state" "$state" "$(tooltip "$state")"
+}
+
+tooltip() {
+ case "$1" in
+ activated) printf '%s: on' "$mode" ;;
+ deactivated) printf '%s: off' "$mode" ;;
+ down) printf '%s: no state file' "$mode" ;;
+ esac
+}
+
+state_now() {
+ [[ -e "$file" ]] || { printf 'down'; return; }
+ [[ "$(read_mode)" == "1" ]] && printf 'activated' || printf 'deactivated'
+}
+
+case "$action" in
+ get)
+ read_mode
+ printf '\n'
+ ;;
+ set)
+ [[ $# -eq 3 ]] || usage
+ case "$3" in
+ 0|1) write_mode "$3" ;;
+ *) usage ;;
+ esac
+ ;;
+ toggle)
+ [[ "$(read_mode)" == "1" ]] && write_mode 0 || write_mode 1
+ ;;
+ watch)
+ emit "$(state_now)"
+ # Watch the directory rather than the file: an atomic write replaces
+ # the file, so a watch held on the old inode dies with it. This is the
+ # same trap the mail watcher hit with Xapian.
+ #
+ # inotifywait must die with us. Piped straight into the while loop it
+ # would be a pipeline sibling, not a child, so a plain kill on this
+ # process (which is exactly how waybar stops and respawns its exec
+ # children on every reload) leaves it running, watching a directory
+ # nobody reads anymore. Process substitution makes it a real child
+ # whose PID we can hold and kill from a trap.
+ exec 3< <(inotifywait -q -m -e close_write,moved_to,delete --format '%f' "$DIR" 2>/dev/null)
+ watcher=$!
+ trap 'kill "$watcher" 2>/dev/null' EXIT TERM INT
+ while read -r changed <&3; do
+ [[ "$changed" == "status.$mode" ]] || continue
+ emit "$(state_now)"
+ done
+ ;;
+ *)
+ usage
+ ;;
+esac
diff --git a/desktop/modules/status/test-statusctl.sh b/desktop/modules/status/test-statusctl.sh
new file mode 100755
index 0000000..3b8f7ba
--- /dev/null
+++ b/desktop/modules/status/test-statusctl.sh
@@ -0,0 +1,87 @@
+#!/bin/bash
+#
+# 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.
+#
+# The one runnable check for statusctl. It points XDG_RUNTIME_DIR at a
+# temporary directory, so nothing here touches the live modes.
+#
+# Usage: ./test-statusctl.sh (exit 0 = all passed)
+
+set -u
+
+here="$(cd "$(dirname "$0")" && pwd)"
+ctl="$here/statusctl"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+export XDG_RUNTIME_DIR="$tmp"
+
+pass=0
+fail=0
+
+check() {
+ local label="$1" want="$2" got="$3"
+ if [[ "$want" == "$got" ]]; then
+ printf 'ok %s\n' "$label"
+ pass=$((pass + 1))
+ else
+ printf 'FAIL %s: want %q, got %q\n' "$label" "$want" "$got"
+ fail=$((fail + 1))
+ fi
+}
+
+# A mode with no file reads as off.
+check "missing file reads 0" "0" "$("$ctl" dnd get)"
+
+# set writes the file and get reads it back.
+"$ctl" dnd set 1
+check "set 1 writes the file" "1" "$(cat "$tmp/status.dnd" | tr -d '[:space:]')"
+check "get after set 1" "1" "$("$ctl" dnd get)"
+
+# toggle flips it.
+"$ctl" dnd toggle
+check "toggle from 1" "0" "$("$ctl" dnd get)"
+"$ctl" dnd toggle
+check "toggle from 0" "1" "$("$ctl" dnd get)"
+
+# set 0 writes rather than removing, so a reader sees an explicit off.
+"$ctl" dnd set 0
+check "set 0 writes the file" "0" "$("$ctl" dnd 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
+check "unknown mode exits non-zero" "1" "$?"
+
+# watch prints a line on change, and the class reflects the value. The
+# atomic write arrives as a rename, which is why the watch needs moved_to.
+out="$tmp/watch.out"
+"$ctl" presentation watch > "$out" 2>/dev/null &
+watcher=$!
+sleep 0.3
+"$ctl" presentation set 1
+sleep 0.5
+kill "$watcher" 2>/dev/null
+wait "$watcher" 2>/dev/null
+check "watch reports activated" "1" "$(grep -c '"class": *"activated"' "$out")"
+
+# A missing file is reported as down, distinct from a mode that is off.
+rm -f "$tmp/status.presentation"
+out2="$tmp/watch2.out"
+"$ctl" presentation watch > "$out2" 2>/dev/null &
+watcher2=$!
+sleep 0.5
+kill "$watcher2" 2>/dev/null
+wait "$watcher2" 2>/dev/null
+check "watch reports down when absent" "1" "$(grep -c '"class": *"down"' "$out2")"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]