Merge branch 'quattro' into unified-input-method-setup
This commit is contained in:
commit
101bb547bb
245 files changed
+19949
-375
No files matched your search
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user