Watch
1
0
Fork
You've already forked souveraine
0

quickshell: Idle & sleep settings page

Live idle stage readout (polled over session IPC), the native-coordinator
toggle, and dim/lock timers. Timers grey out while the coordinator is off.
This commit is contained in:
Fimeg 2026-07-14 20:17:18 -04:00
commit 4d5690b547
4 changed files with 342 additions and 0 deletions

View file

@ -28,6 +28,7 @@ GlobalStates.qml souveraine/GlobalStates.qml
settings-phone.qml souveraine/settings-phone.qml
panelFamilies/SouveraineFamily.qml souveraine/panelFamilies/SouveraineFamily.qml
services/Souveraine.qml souveraine/services/Souveraine.qml
services/Audio.qml souveraine/services/Audio.qml
services/Ai.qml souveraine/services/Ai.qml
services/TaskbarApps.qml souveraine/services/TaskbarApps.qml
services/GlobalFocusGrab.qml souveraine/services/GlobalFocusGrab.qml
@ -49,6 +50,7 @@ modules/settings/LockConfig.qml souveraine/modules/settings/LockConfig.qml
modules/settings/DockConfig.qml souveraine/modules/settings/DockConfig.qml
modules/settings/NavigationConfig.qml souveraine/modules/settings/NavigationConfig.qml
modules/settings/KeyboardConfig.qml souveraine/modules/settings/KeyboardConfig.qml
modules/settings/IdleConfig.qml souveraine/modules/settings/IdleConfig.qml
modules/ii/polkit/Polkit.qml souveraine/modules/ii/polkit/Polkit.qml
modules/ii/dock/Dock.qml souveraine/modules/ii/dock/Dock.qml
modules/ii/dock/DockManifest.qml souveraine/modules/ii/dock/DockManifest.qml

View file

@ -0,0 +1,157 @@
import QtQuick
import QtQuick.Layouts
import Quickshell.Io
import qs.services
import qs.modules.common
import qs.modules.common.widgets
// Idle & sleep the staged idle projection, exposed honestly.
//
// The governing idea is REFERENCE-EXTRACTION.md's "idle is a transition graph,
// not a timer": the page shows the live stage the shell is actually in, not
// just three timeout knobs pretending idle is linear.
//
// Two honesty constraints drive the layout, and both come straight from the
// extraction's build order (truth before visuals):
//
// 1. The native idle coordinator is OFF by default and stays experimental
// until the Wayland idle-notify is verified on the Pixel compositor.
// While it is off, the dim/lock TIMERS DO NOT RUN hypridle owns real
// screen-off, and the only transitions that fire are lock-request and
// lock-secure. The page says so instead of implying the timers are live.
//
// 2. This settings app is a SEPARATE process from the shell, so it cannot
// read the IdleCoordinator singleton directly. It reads the live stage
// over the same `session` IPC the arbiter already exposes
// (session.state().idle.stage), on a light poll.
ContentPage {
id: page
forceWidth: true
// --- Live stage readout (polled over IPC) ----------------------------
// 0 Active · 1 Dimmed · 2 Lock requested · 3 Lock secure the
// IdleCoordinator.State enum, surfaced through session.state().
property int liveStage: -1
property bool liveNative: false
property bool probeOk: false
readonly property var stageNames: [
Translation.tr("Active"),
Translation.tr("Dimmed"),
Translation.tr("Lock requested"),
Translation.tr("Lock secure")
]
function stageLabel(s) {
return (s >= 0 && s < stageNames.length) ? stageNames[s] : Translation.tr("unknown");
}
Process {
id: stateProbe
command: ["qs", "-c", "souveraine", "ipc", "call", "session", "state"]
stdout: StdioCollector {
id: stateOut
onStreamFinished: {
try {
const d = JSON.parse(stateOut.text);
page.liveStage = d.idle?.stage ?? -1;
page.liveNative = d.idle?.nativeCoordinatorEnabled ?? false;
page.probeOk = true;
} catch (e) {
page.probeOk = false;
}
}
}
}
Timer {
// Light poll: the stage only changes on idle/lock transitions, so a
// 2s cadence is plenty and cheap. Runs only while this page is shown.
interval: 2000
running: page.visible
repeat: true
triggeredOnStart: true
onTriggered: stateProbe.running = true
}
ContentSection {
icon: "motion_sensor_active"
title: Translation.tr("Current state")
RowLayout {
Layout.fillWidth: true
spacing: 12
StyledText {
text: page.probeOk
? Translation.tr("Idle stage: %1").arg(page.stageLabel(page.liveStage))
: Translation.tr("Idle stage: (shell not reachable)")
font.pixelSize: Appearance.font.pixelSize.normal
}
}
StyledText {
Layout.fillWidth: true
wrapMode: Text.WordWrap
color: Appearance.colors.colSubtext
font.pixelSize: Appearance.font.pixelSize.smaller
text: page.liveNative
? Translation.tr("The native coordinator is driving idle transitions. Dim and lock fire on the timers below.")
: Translation.tr("The native coordinator is off, so the timers below do not run — hypridle owns screen-off. Only lock-requested and lock-secure transitions are reported here.")
}
}
ContentSection {
icon: "experiment"
title: Translation.tr("Native idle coordinator")
ConfigSwitch {
buttonIcon: "science"
text: Translation.tr("Enable native coordinator (experimental)")
checked: Config.options.lock.idle.nativeCoordinatorEnabled
onCheckedChanged: {
Config.options.lock.idle.nativeCoordinatorEnabled = checked;
}
StyledToolTip {
text: Translation.tr("Use Wayland idle-notify to drive the dim/lock timers. Unverified on the Pixel 3 compositor build — leave off unless you are testing it. When off, hypridle handles idle and screen-off.")
}
}
}
ContentSection {
icon: "timer"
title: Translation.tr("Timers")
// These write real Config keys, but they only take EFFECT when the
// native coordinator is on. Disabled (greyed) otherwise so the page
// never implies a knob is doing something it isn't.
ConfigSpinBox {
icon: "brightness_low"
text: Translation.tr("Dim after (seconds)")
value: Config.options.lock.idle.dimAfterSeconds
from: 5
to: 600
stepSize: 5
enabled: Config.options.lock.idle.nativeCoordinatorEnabled
onValueChanged: Config.options.lock.idle.dimAfterSeconds = value
StyledToolTip {
text: Translation.tr("Seconds of inactivity before the screen dims. Only active while the native coordinator is enabled.")
}
}
ConfigSpinBox {
icon: "lock_clock"
text: Translation.tr("Lock after (seconds)")
value: Config.options.lock.idle.lockAfterSeconds
from: 10
to: 1800
stepSize: 10
enabled: Config.options.lock.idle.nativeCoordinatorEnabled
onValueChanged: Config.options.lock.idle.lockAfterSeconds = value
StyledToolTip {
text: Translation.tr("Seconds of inactivity before the session locks. Only active while the native coordinator is enabled.")
}
}
}
}

View file

@ -0,0 +1,178 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
/*
* PulseAudio-backed replacement for ii's PipeWire Audio singleton.
*
* The Pixel 3 uses native PulseAudio because its Q6 PCM needs the patched
* module-alsa-sink fallback. Quickshell has a PipeWire service but no native
* PulseAudio service, so keep ii's public Audio API and mirror pactl's default
* sink into the small node-shaped object the existing controls consume.
*/
Singleton {
id: root
property bool ready: false
property bool syncing: false
property bool autoMuted: false
readonly property real hardMaxValue: 1.00
property string audioTheme: Config.options.sounds.theme
readonly property real value: sink.audio.volume
property QtObject sink: QtObject {
id: sinkNode
property string id: name
property string name: ""
property string description: Translation.tr("Internal speakers")
property string nickname: description
property bool isSink: true
property var properties: ({ "node.name": name })
property QtObject audio: QtObject {
property real volume: 0
property bool muted: false
onVolumeChanged: {
if (root.ready && !root.syncing)
root.setVolume(volume)
}
onMutedChanged: {
if (root.ready && !root.syncing)
root.setMuted(muted)
}
}
}
// A capture source is intentionally absent until the ADSP capture route
// produces real samples. The ii controls already handle this as optional.
property var source: null
readonly property list<var> outputDevices: sink.name.length > 0 ? [sink] : []
readonly property list<var> inputDevices: []
readonly property list<var> outputAppNodes: []
readonly property list<var> inputAppNodes: []
signal sinkProtectionTriggered(string reason)
function friendlyDeviceName(node) {
return node?.nickname || node?.description || Translation.tr("Unknown")
}
function appNodeDisplayName(node) {
return node?.properties?.["application.name"] || node?.description || node?.name || Translation.tr("Unknown")
}
function refresh() {
if (!statusProcess.running)
statusProcess.running = true
}
function toggleMute() {
if (!ready)
return
autoMuted = false
setMuted(!sink.audio.muted)
}
function toggleMicMute() {
if (source?.audio)
source.audio.muted = !source.audio.muted
}
function incrementVolume() {
setVolume(Math.min(hardMaxValue, value + (value < 0.1 ? 0.01 : 0.02)))
}
function decrementVolume() {
setVolume(Math.max(0, value - (value < 0.1 ? 0.01 : 0.02)))
}
function setVolume(nextVolume) {
if (!ready || !isFinite(nextVolume))
return
const bounded = Math.max(0, Math.min(hardMaxValue, Number(nextVolume)))
volumeProcess.exec(["pactl", "set-sink-volume", "@DEFAULT_SINK@", `${Math.round(bounded * 100)}%`])
refreshSoon.restart()
}
function setMuted(muted) {
if (!ready)
return
muteProcess.exec(["pactl", "set-sink-mute", "@DEFAULT_SINK@", muted ? "1" : "0"])
refreshSoon.restart()
}
function setDefaultSink(node) {
if (!node?.name)
return
defaultSinkProcess.exec(["pactl", "set-default-sink", node.name])
refreshSoon.restart()
}
function setDefaultSource(node) {
if (!node?.name)
return
defaultSourceProcess.exec(["pactl", "set-default-source", node.name])
refreshSoon.restart()
}
// All three status reads must be from the same PulseAudio default sink.
// The tagged output avoids relying on locale-sensitive pactl labels.
Process {
id: statusProcess
command: ["sh", "-c", "printf 'name='; pactl get-default-sink; printf 'volume='; pactl get-sink-volume @DEFAULT_SINK@; printf 'mute='; pactl get-sink-mute @DEFAULT_SINK@"]
stdout: StdioCollector {
onStreamFinished: {
const text = this.text
const name = /^name=(.+)$/m.exec(text)?.[1]?.trim()
const volume = /^volume=.*?(\d+)%/m.exec(text)?.[1]
const muted = /^mute=(yes|no)$/m.exec(text)?.[1]
if (!name || volume === undefined || muted === undefined) {
root.ready = false
return
}
root.syncing = true
sinkNode.name = name
sinkNode.description = name === "alsa_output.hw_0_0"
? Translation.tr("Internal speakers") : name
sinkNode.audio.volume = Math.max(0, Math.min(root.hardMaxValue, Number(volume) / 100))
sinkNode.audio.muted = muted === "yes"
root.syncing = false
root.ready = true
}
}
onExited: exitCode => {
if (exitCode !== 0)
root.ready = false
}
}
Process { id: volumeProcess }
Process { id: muteProcess }
Process { id: defaultSinkProcess }
Process { id: defaultSourceProcess }
Timer {
id: refreshSoon
interval: 120
repeat: false
onTriggered: root.refresh()
}
Timer {
interval: 1500
repeat: true
running: true
triggeredOnStart: true
onTriggered: root.refresh()
}
function playSystemSound(soundName) {
const base = `/usr/share/sounds/${audioTheme}/stereo/${soundName}`
Quickshell.execDetached(["sh", "-c", `ffplay -nodisp -autoexit \"${base}.oga\" 2>/dev/null || ffplay -nodisp -autoexit \"${base}.ogg\" 2>/dev/null`])
}
}

View file

@ -53,6 +53,11 @@ ApplicationWindow {
icon: "gesture",
component: "modules/settings/NavigationConfig.qml"
},
{
name: Translation.tr("Idle & sleep"),
icon: "bedtime",
component: "modules/settings/IdleConfig.qml"
},
{
name: Translation.tr("Keyboard"),
icon: "keyboard",