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:
parent
e31c3aaf62
commit
4d5690b547
4 changed files with 342 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
157
surfaces/quickshell/modules/settings/IdleConfig.qml
Normal file
157
surfaces/quickshell/modules/settings/IdleConfig.qml
Normal 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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
178
surfaces/quickshell/services/Audio.qml
Normal file
178
surfaces/quickshell/services/Audio.qml
Normal 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`])
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue