diff options
Diffstat (limited to 'desktop/modules')
| -rw-r--r-- | desktop/modules/bluetooth/BluetoothPage.qml | 11 | ||||
| -rw-r--r-- | desktop/modules/network/NetworkPage.qml | 9 | ||||
| -rw-r--r-- | desktop/modules/status/README.md | 59 | ||||
| -rw-r--r-- | desktop/modules/status/StatusModule.qml | 39 | ||||
| -rw-r--r-- | desktop/modules/status/StatusPage.qml | 43 | ||||
| -rw-r--r-- | desktop/modules/status/StatusRow.qml | 61 | ||||
| -rw-r--r-- | desktop/modules/status/StatusTile.qml | 23 | ||||
| -rwxr-xr-x | desktop/modules/status/statusctl | 132 | ||||
| -rwxr-xr-x | desktop/modules/status/test-statusctl.sh | 87 |
9 files changed, 464 insertions, 0 deletions
diff --git a/desktop/modules/bluetooth/BluetoothPage.qml b/desktop/modules/bluetooth/BluetoothPage.qml index b47c506..5bfdc0b 100644 --- a/desktop/modules/bluetooth/BluetoothPage.qml +++ b/desktop/modules/bluetooth/BluetoothPage.qml @@ -82,6 +82,17 @@ Column { text: page.bt.adapter && page.bt.adapter.discovering ? "Stop scan" : "Scan" onClicked: if (page.bt.adapter) page.bt.adapter.discovering = !page.bt.adapter.discovering } + + // BlueZ discovery runs until stopped, and closing the drawer does not + // stop it, so a forgotten scan keeps the radio busy for the session. + // Bound to the property rather than started by the button: a manual + // stop cancels the clock, and reopening the page re-arms a fresh 60s + // on a scan still running from before. + Timer { + interval: 60000 + running: page.bt.adapter ? page.bt.adapter.discovering : false + onTriggered: if (page.bt.adapter) page.bt.adapter.discovering = false + } } // --- Connected --- diff --git a/desktop/modules/network/NetworkPage.qml b/desktop/modules/network/NetworkPage.qml index 975e68c..3749688 100644 --- a/desktop/modules/network/NetworkPage.qml +++ b/desktop/modules/network/NetworkPage.qml @@ -127,6 +127,15 @@ Column { text: page.net.wifiDevice && page.net.wifiDevice.scannerEnabled ? "Scanning…" : "Scan" onClicked: if (page.net.wifiDevice) page.net.wifiDevice.scannerEnabled = !page.net.wifiDevice.scannerEnabled } + + // The scanner repeats until disabled, and closing the drawer does not + // disable it. A continuous scan also costs throughput on the connected + // link. See the matching timer in BluetoothPage. + Timer { + interval: 60000 + running: page.net.wifiDevice ? page.net.wifiDevice.scannerEnabled : false + onTriggered: if (page.net.wifiDevice) page.net.wifiDevice.scannerEnabled = false + } } Repeater { 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/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..850cc73 --- /dev/null +++ b/desktop/modules/status/StatusPage.qml @@ -0,0 +1,43 @@ +// 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 + } +} 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 ]] |
