Merge branch 'quattro' into unified-input-method-setup

This commit is contained in:
David Heinemeier Hansson committed 2026-10-10 15:51:52 +02:00
commit 101bb547bb
245 files changed
+19949 -375

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import "AudioNodesModel.js" as Model
// Which PipeWire nodes the audio panel and the microphone widget leave out:
// the shell's own level meters, and the nodes a platform's audio processing
// adds, which its platform package names in the fixed platform root (see
// Model.parsePlatformAudio). No environment variable moves that file, and most
// machines have none, so nothing of theirs is left out.
Singleton {
id: root
readonly property var hints: Model.parsePlatformAudio(hintsFile.missing ? "" : hintsFile.text())
// Whether the platform's hints hide a node, given the names of every node
// now in the graph (for a "replaced" node).
function platformHides(name, nodeNames) {
return Model.platformHidesNode(name, hints, nodeNames)
}
function isShellLevelMeter(name) {
return Model.isShellLevelMeter(name)
}
FileView {
id: hintsFile
// A file that went away hints nothing, whatever text() still holds.
property bool missing: false
path: "/usr/share/omarchy-platform/audio.json"
blockLoading: true
watchChanges: true
printErrors: false
onLoaded: missing = false
onLoadFailed: missing = true
onFileChanged: reload()
}
}
+96
View File
@@ -0,0 +1,96 @@
// Audio node rules the bar and the audio panel share. Plain functions, so the
// tests load this file in Node.
// The level meter the shell runs itself: Quickshell's peak monitor stream,
// named after the shell or after its stream depending on the PipeWire version.
// It is not an app using the input.
function isShellLevelMeter(name) {
var value = String(name || "")
return value === "quickshell" || value === "quickshell-peak-monitor"
}
function wholeNamePattern(pattern) {
if (typeof pattern !== "string" || pattern === "") return null
try {
return new RegExp("^(?:" + pattern + ")$")
} catch (e) {
return null
}
}
// A platform's audio hints, which its own package ships in the platform root
// (/usr/share/omarchy-platform/audio.json, read by AudioNodes.qml; Omarchy
// ships none): nodes of its audio processing that are neither devices nor apps.
//
// { "hidden": ["<pattern>", ...],
// "replaced": [{ "node": "<pattern>", "by": "<pattern>" }, ...] }
//
// A pattern is a regular expression matched against a whole node.name. A
// hidden node is never listed as an output, an input or an app stream, and
// never counts as a recording; a replaced node is hidden while a node matching
// "by" exists. Missing or malformed text means no hints, and an invalid pattern
// or entry is skipped.
function parsePlatformAudio(text) {
var hints = { hidden: [], replaced: [] }
var data = null
try {
data = JSON.parse(String(text || ""))
} catch (e) {
return hints
}
if (!data || typeof data !== "object" || Array.isArray(data)) return hints
var hidden = Array.isArray(data.hidden) ? data.hidden : []
for (var i = 0; i < hidden.length; i++) {
var pattern = wholeNamePattern(hidden[i])
if (pattern) hints.hidden.push(pattern)
}
var replaced = Array.isArray(data.replaced) ? data.replaced : []
for (var j = 0; j < replaced.length; j++) {
var entry = replaced[j]
if (!entry || typeof entry !== "object") continue
var node = wholeNamePattern(entry.node)
var by = wholeNamePattern(entry.by)
if (node && by) hints.replaced.push({ node: node, by: by })
}
return hints
}
// Whether the platform's hints hide a node, given the names of every node now
// in the graph (for "replaced").
function platformHidesNode(name, hints, nodeNames) {
var value = String(name || "")
if (value === "" || !hints) return false
var hidden = hints.hidden || []
for (var i = 0; i < hidden.length; i++)
if (hidden[i].test(value)) return true
var replaced = hints.replaced || []
var names = Array.isArray(nodeNames) ? nodeNames : []
for (var j = 0; j < replaced.length; j++) {
if (!replaced[j].node.test(value)) continue
for (var k = 0; k < names.length; k++) {
var other = String(names[k] || "")
if (other !== value && replaced[j].by.test(other)) return true
}
}
return false
}
// `wpctl get-volume <id>` prints "Volume: 0.50", with " [MUTED]" when muted, on
// the same cubic scale as PwNodeAudio.volume.
function parseWpctlVolume(text) {
var match = /^Volume:\s+([0-9]+(?:\.[0-9]+)?)(\s+\[MUTED\])?$/.exec(String(text || "").trim())
if (!match) return null
return { volume: parseFloat(match[1]), muted: !!match[2] }
}
if (typeof module !== "undefined") {
module.exports = {
isShellLevelMeter: isShellLevelMeter,
parsePlatformAudio: parsePlatformAudio,
platformHidesNode: platformHidesNode,
parseWpctlVolume: parseWpctlVolume
}
}
+8 -1
View File
@@ -343,6 +343,13 @@ QtObject {
readonly property QtObject bar: QtObject {
readonly property int sizeHorizontal: root.barToken("size-horizontal", 26)
readonly property int sizeVertical: root.barToken("size-vertical", 28)
// Calibrated notch cutout height in logical px, for panels whose real
// cutout differs from the derived default (see BarModel.notchHeight).
// 0 means derive it. Physical alignment, so it never scales with font.
readonly property int notchHeight: {
var v = Number(root.barOverrides["notch-height"])
return (isFinite(v) && v > 0) ? Math.round(v) : 0
}
readonly property int iconSlot: root.barToken("icon-slot", 27)
readonly property int iconCanvas: root.barToken("icon-canvas", 16)
readonly property int iconFont: root.barToken("icon-font", 13)
@@ -417,7 +424,7 @@ QtObject {
} else if (section === "bar") {
if (key === "scale-with-font") {
nextBarScaleWithFont = boolToken(raw, nextBarScaleWithFont)
} else if (key === "size-horizontal" || key === "size-vertical") {
} else if (key === "size-horizontal" || key === "size-vertical" || key === "notch-height") {
var b = parseInt(raw, 10)
if (isFinite(b)) barOut[key] = b
}
+152
View File
@@ -0,0 +1,152 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pipewire
import "AudioNodesModel.js" as Model
// Volume and mute of the default input when Quickshell gives it no
// PwNode.audio: Quickshell types only exact media classes, so a virtual source
// (an Audio/Source/Virtual, such as EasyEffects' or a platform's microphone
// mapping) has no volume or mute there. They go through wpctl instead, once for
// the whole shell. Reads and writes name the node by id, a reply for a node no
// longer tracked is dropped, and writes run one at a time with only the latest
// volume kept, so a slider drag cannot land on another input later.
Singleton {
id: root
readonly property var source: Pipewire.defaultAudioSource
// The default input is untyped, so this drives it.
readonly property bool active: !!source && !source.audio
// Every handler reads nodeId itself: a property bound to it could still hold
// its old value when onNodeIdChanged runs.
readonly property int nodeId: active ? source.id : -1
property bool known: false
property real volume: 0
property bool muted: false
property int generation: 0
property int readGeneration: -1
property bool readAgain: false
property var pendingVolume: null
property var pendingMute: null
// pactl subscribe is started again when it ends, unless it keeps ending at
// once (no pipewire-pulse), which would only respawn it forever.
property real subscribedAt: 0
property int quickExits: 0
onNodeIdChanged: reset()
Component.onCompleted: reset()
function reset() {
generation++
known = false
pendingVolume = null
pendingMute = null
quickExits = 0
events.running = nodeId >= 0
refresh()
}
function refresh() {
if (nodeId < 0) return
if (reader.running || writer.running) {
readAgain = true
return
}
readGeneration = generation
reader.command = ["wpctl", "get-volume", String(nodeId)]
reader.running = true
}
function setVolume(value) {
if (nodeId < 0) return
volume = Math.max(0, Math.min(1, value))
pendingVolume = [String(nodeId), volume.toFixed(2)]
flush()
}
function setMuted(value) {
if (nodeId < 0 || !known) return
muted = value
pendingMute = [String(nodeId), value ? "1" : "0"]
flush()
}
function flush() {
if (reader.running || writer.running) return true
var args = null
if (pendingMute !== null) {
args = ["wpctl", "set-mute"].concat(pendingMute)
pendingMute = null
} else if (pendingVolume !== null) {
args = ["wpctl", "set-volume"].concat(pendingVolume)
pendingVolume = null
}
if (!args) return false
writer.command = args
writer.running = true
return true
}
function settle() {
if (flush()) return
if (readAgain) {
readAgain = false
refresh()
}
}
Process {
id: reader
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
var level = Model.parseWpctlVolume(text)
if (!level || root.nodeId < 0 || root.readGeneration !== root.generation) return
if (root.pendingVolume !== null || root.pendingMute !== null || writer.running) return
root.volume = level.volume
root.muted = level.muted
root.known = true
}
}
onExited: root.settle()
}
Process {
id: writer
onExited: {
root.readAgain = true
root.settle()
}
}
// The volume keys, the mute key and other apps change the node too. pactl
// translates its events, so they are read in the C locale.
Process {
id: events
command: ["pactl", "subscribe"]
environment: ({ LC_ALL: "C" })
onRunningChanged: if (running) root.subscribedAt = Date.now()
onExited: {
root.quickExits = Date.now() - root.subscribedAt < 1000 ? root.quickExits + 1 : 0
if (root.nodeId >= 0 && root.quickExits < 5) resubscribe.start()
}
stdout: SplitParser {
onRead: function(line) {
if (String(line).indexOf(" on source #") !== -1) root.refresh()
}
}
}
Timer {
id: resubscribe
interval: 2000
onTriggered: if (root.nodeId >= 0 && !events.running) {
events.running = true
root.refresh()
}
}
}
+2
View File
@@ -1,8 +1,10 @@
module qs.Commons
singleton AudioNodes 1.0 AudioNodes.qml
singleton Border 1.0 Border.qml
singleton Color 1.0 Color.qml
singleton IpcRegistry 1.0 IpcRegistry.qml
singleton InputMethodState 1.0 InputMethodState.qml
singleton Style 1.0 Style.qml
singleton UntypedInput 1.0 UntypedInput.qml
singleton Util 1.0 Util.qml
ShellIpc 1.0 ShellIpc.qml