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
+1
View File
@@ -34,6 +34,7 @@ User-installed plugins live alongside these conceptually but on disk under
| Battery | `omarchy.battery` | `service` | `services/battery/Service.qml` |
| Idle | `omarchy.idle` | `service` | `services/idle/Service.qml` |
| Night light | `omarchy.nightlight` | `service` | `services/nightlight/Service.qml` |
| Remote session | `omarchy.remote-session` | `service` | `services/remote-session/Service.qml` |
| Lock screen | `omarchy.lock` | `service` | `lock/Service.qml` |
| OSD | `omarchy.osd` | `panel` | `osd/Osd.qml` |
| Polkit agent | `omarchy.polkit` | `service` | `polkit/PolkitAgent.qml` |
+105 -11
View File
@@ -569,6 +569,61 @@ Item {
readonly property bool vertical: position === "left" || position === "right"
readonly property int barSize: vertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal
// Display cutouts (a camera notch) the platform's own package describes in
// the fixed platform root, which no environment variable moves. Most machines
// have none. It is read as the shell starts (blockLoading), so a bar surface
// knows before it maps whether its screen may have one. See
// BarModel.parseCutouts.
readonly property var displayCutouts: displayCutoutsFile.missing ? [] : BarModel.parseCutouts(displayCutoutsFile.text())
FileView {
id: displayCutoutsFile
// A file that went away describes nothing, whatever text() still holds.
property bool missing: false
path: "/usr/share/omarchy-platform/display-cutouts.json"
blockLoading: true
watchChanges: true
printErrors: false
onLoaded: missing = false
onLoadFailed: missing = true
onFileChanged: reload()
}
// The physical mode Hyprland reports for a screen, as hyprctl monitors does.
// Qt's whole-number devicePixelRatio can't rebuild it at a fractional scale.
function panelModeFor(screen) {
var monitor = screen ? Hyprland.monitorFor(screen) : null
return monitor ? ({
width: monitor.width,
height: monitor.height,
transform: monitor.lastIpcObject ? monitor.lastIpcObject.transform : 0
}) : null
}
// How thick the bar is on a screen. A top bar shorter than a panel's camera
// cutout leaves a sliver of every window peeking out beside the camera, so
// the cutout is this panel's minimum sensible top-bar height. An
// intentionally taller bar still wins.
function thicknessFor(screen) {
if (root.vertical || !screen) return root.barSize
return Math.max(root.barSize, BarModel.notchFloor(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, root.panelModeFor(screen), Style.bar.notchHeight))
}
// Each screen's bar thickness, by screen name: barSize, or more where a notch
// floor raises a top bar. Toasts and plugins clear the bar by it; barSize
// stays the configured size the widgets are drawn at.
readonly property var screenBarSizes: {
var sizes = ({})
var screens = Quickshell.screens
for (var i = 0; i < screens.length; i++) sizes[screens[i].name] = root.thicknessFor(screens[i])
return sizes
}
function barSizeFor(screenName) {
return BarModel.barSizeFor(root.screenBarSizes, screenName, root.barSize)
}
function normalizePosition(value) {
return BarModel.normalizePosition(value)
}
@@ -1253,7 +1308,12 @@ Item {
// reveal has to rebuild them — new surface, re-shaped glyphs, re-uploaded
// textures — which measures ~150ms against ~20ms to tear down. Parking
// keeps the surface alive, so showing is only a margin change.
visible: !remapGuard.remapping
//
// A top bar that can't tell its notch floor yet waits for Hyprland's mode
// before it maps (see BarModel.cutoutPending), so it maps at its floor
// instead of growing once the windows are laid out. The wait ends once, for
// good: when the mode settles it, or after two seconds without one.
visible: !remapGuard.remapping && (cutoutWaitOver || !cutoutPending)
exclusionMode: root.barHidden ? ExclusionMode.Ignore : ExclusionMode.Auto
ScreenMoveRemap {
@@ -1261,11 +1321,13 @@ Item {
window: barWindow
}
// Parked by its full thickness, which a notch floor may make more than
// barSize, so no strip of it stays on screen.
margins {
top: root.barHidden && root.position === "top" ? -root.barSize : 0
bottom: root.barHidden && root.position === "bottom" ? -root.barSize : 0
left: root.barHidden && root.position === "left" ? -root.barSize : 0
right: root.barHidden && root.position === "right" ? -root.barSize : 0
top: root.barHidden && root.position === "top" ? -barWindow.thickness : 0
bottom: root.barHidden && root.position === "bottom" ? -barWindow.thickness : 0
left: root.barHidden && root.position === "left" ? -barWindow.thickness : 0
right: root.barHidden && root.position === "right" ? -barWindow.thickness : 0
}
anchors {
@@ -1275,8 +1337,26 @@ Item {
right: root.position === "right" || !root.vertical
}
implicitWidth: root.vertical ? root.barSize : 0
implicitHeight: root.vertical ? 0 : root.barSize
readonly property var panelMode: root.panelModeFor(screen)
readonly property bool centerBesideRight: BarModel.centerBesideRight(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, panelMode)
readonly property bool cutoutPending: BarModel.cutoutPending(root.displayCutouts, root.position, screen.name, screen.width, screen.height, screen.devicePixelRatio, panelMode)
property bool cutoutWaitOver: false
onCutoutPendingChanged: if (!cutoutPending) cutoutWaitOver = true
Component.onCompleted: if (!cutoutPending) cutoutWaitOver = true
Timer {
interval: 2000
running: barWindow.cutoutPending && !barWindow.cutoutWaitOver
onTriggered: barWindow.cutoutWaitOver = true
}
// The same thickness the bar publishes for its screen (screenBarSizes).
readonly property int thickness: root.thicknessFor(screen)
implicitWidth: root.vertical ? thickness : 0
implicitHeight: root.vertical ? 0 : thickness
color: root.transparent ? "transparent" : root.background
surfaceFormat.opaque: false
WlrLayershell.namespace: "omarchy-bar"
@@ -1367,7 +1447,10 @@ Item {
Item {
anchors.fill: parent
CenterModules { anchors.fill: parent }
CenterModules {
anchors.fill: parent
entries: barWindow.centerBesideRight ? [] : root.layoutEntries("center")
}
LeftModules {
anchors.left: parent.left
@@ -1376,10 +1459,22 @@ Item {
}
RightModules {
id: rightModules
anchors.right: parent.right
anchors.rightMargin: Style.space(8)
anchors.verticalCenter: parent.verticalCenter
}
// Keeps the center region, so settings pushes, drag and drop, and panel
// routing still address it. The gap lets a drop at the seam land in
// the section the pointer is nearer to.
ModuleList {
entries: barWindow.centerBesideRight ? root.layoutEntries("center") : []
region: "center"
anchors.right: rightModules.left
anchors.rightMargin: Style.space(4)
anchors.verticalCenter: parent.verticalCenter
}
}
}
@@ -1529,8 +1624,7 @@ Item {
}
}
function findCenterAnchorEntry() {
var entries = root.layoutEntries("center")
function findCenterAnchorEntry(entries) {
var idx = root.entryIndex(entries, root.centerAnchor)
return idx === -1 ? null : entries[idx]
}
@@ -1550,7 +1644,7 @@ Item {
property var entries: root.layoutEntries("center")
readonly property bool hasAnchor: root.entryIndex(entries, root.centerAnchor) !== -1
readonly property var anchorEntry: root.findCenterAnchorEntry()
readonly property var anchorEntry: root.findCenterAnchorEntry(entries)
Loader {
anchors.fill: parent
+113
View File
@@ -208,9 +208,122 @@ function nearestDropTarget(candidates, point, vertical) {
return best
}
// Display cutouts (a camera notch at the top of a laptop panel) are described
// by the platform's own package, in /usr/share/omarchy-platform/display-cutouts.json:
//
// { "panels": [ { "connector": "eDP", "width": 3024, "height": 1964, "top": 64 } ] }
//
// A panel matches a screen whose connector name starts with `connector` and
// whose mode is width x height physical pixels; `top` is how many physical rows
// at its top the cutout covers. Anything malformed is dropped.
function parseCutouts(text) {
var parsed
try {
parsed = JSON.parse(String(text || ""))
} catch (error) {
return []
}
var panels = parsed && Array.isArray(parsed.panels) ? parsed.panels : []
var cutouts = []
for (var i = 0; i < panels.length; i++) {
var panel = panels[i] || {}
var width = Number(panel.width)
var height = Number(panel.height)
var top = Number(panel.top)
if (typeof panel.connector !== "string" || panel.connector === "") continue
if (!(width > 0) || !(height > 0) || !(top > 0) || top >= height) continue
cutouts.push({ connector: panel.connector, width: width, height: height, top: top })
}
return cutouts
}
// The cutout at the top of this screen, in logical pixels, or 0. `mode` is the
// output's physical mode as Hyprland reports it ({ width, height, transform }).
// Qt's devicePixelRatio is a whole number even at a fractional scale, so the
// logical size times it rebuilds the mode only at whole scales; that stands in
// just until Hyprland's answer arrives. A mode Hyprland reports that matches no
// panel has no cutout.
function cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) {
var name = String(screenName || "")
var logical = Number(logicalWidth)
var logicalTall = Number(logicalHeight)
if (!(logical > 0) || !(logicalTall > 0)) return 0
var width = Number(mode && mode.width)
var height = Number(mode && mode.height)
if (width > 0 && height > 0) {
// Turned a quarter or upside down, the cutout is on another edge.
var transform = Number(mode.transform) || 0
if (transform !== 0 && transform !== 4) return 0
if ((width > height) !== (logical > logicalTall)) return 0
} else {
var scale = Number(devicePixelRatio) > 0 ? Number(devicePixelRatio) : 1
width = Math.round(logical * scale)
height = Math.round(logicalTall * scale)
}
var list = Array.isArray(cutouts) ? cutouts : []
for (var i = 0; i < list.length; i++) {
var panel = list[i]
if (name.indexOf(panel.connector) !== 0) continue
// Logical sizes are rounded, so a rebuilt mode can be a couple of pixels off.
if (Math.abs(width - panel.width) <= 4 && Math.abs(height - panel.height) <= 4)
return Math.ceil(panel.top * logical / width)
}
return 0
}
// The height a top bar on this screen must reach to cover its cutout, or a
// calibrated [bar] notch-height in its place. A screen without a cutout, or a
// bar on another edge, has no floor, so a calibration never reaches an
// external monitor.
function notchFloor(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode, calibrated) {
if (position !== "top") return 0
var top = cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode)
if (!(top > 0)) return 0
return Number(calibrated) > 0 ? Math.round(Number(calibrated)) : top
}
// A cutout covers the middle of a top bar, so that bar draws its center
// section beside the right one.
function centerBesideRight(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) {
return position === "top" && cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) > 0
}
// Whether a top bar can't tell its floor yet: Hyprland hasn't reported the
// screen's mode, the logical size times Qt's ratio matched no panel (as at a
// fractional scale), and a described panel's connector matches the screen, so
// the mode may still bring a cutout. Such a bar waits for the mode before it
// maps rather than guess a floor. A screen no entry's connector matches never
// waits.
function cutoutPending(cutouts, position, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) {
if (position !== "top") return false
if (Number(mode && mode.width) > 0 && Number(mode && mode.height) > 0) return false
var name = String(screenName || "")
var list = Array.isArray(cutouts) ? cutouts : []
var described = false
for (var i = 0; i < list.length; i++) {
if (name.indexOf(list[i].connector) === 0) described = true
}
return described && !(cutoutTop(cutouts, screenName, logicalWidth, logicalHeight, devicePixelRatio, mode) > 0)
}
// The bar's thickness on the screen with this name, from the sizes a bar
// publishes by screen name, or `fallback` (its configured size) for a screen
// it has none for.
function barSizeFor(sizes, screenName, fallback) {
var name = String(screenName || "")
var size = sizes && Object.prototype.hasOwnProperty.call(sizes, name) ? Number(sizes[name]) : 0
return size > 0 ? size : fallback
}
if (typeof module !== "undefined") {
module.exports = {
barSizeFor: barSizeFor,
centerBesideRight: centerBesideRight,
cutoutPending: cutoutPending,
cutoutTop: cutoutTop,
isDrawnSlot: isDrawnSlot,
notchFloor: notchFloor,
parseCutouts: parseCutouts,
pickDrawnSlot: pickDrawnSlot,
pickPanelSlot: pickPanelSlot,
nearestDropTarget: nearestDropTarget,
@@ -0,0 +1,26 @@
import QtQuick
import qs.Commons as Commons
import qs.Ui
BarIndicator {
id: root
readonly property var remoteSessionService: bar?.shell?.firstPartyServiceFor("omarchy.remote-session")
readonly property var peers: remoteSessionService && Array.isArray(remoteSessionService.peers) ? remoteSessionService.peers : []
active: remoteSessionService ? remoteSessionService.active : false
activeText: "󰢹"
inactiveText: "󰢹"
activeTooltipText: peers.length > 0 ? "Remote session from " + peers.join(", ") : "Remote session"
inactiveTooltipText: "Remote Session"
useActiveColor: true
activeColor: Commons.Color.urgent
Connections {
target: root.indicatorHost
ignoreUnknownSignals: true
function onRefreshRequested() {
if (root.remoteSessionService) root.remoteSessionService.refresh()
}
}
}
@@ -15,7 +15,7 @@ BarIndicator {
function refresh() {
if (!root.bar || statusProc.running) return
statusProc.command = ["pgrep", "--quiet", "-f", "^gpu-screen-recorder"]
statusProc.command = ["omarchy-capture-screenrecording", "--status"]
statusProc.running = true
}
@@ -36,6 +36,11 @@
"label": "Screen recording",
"description": "GPU screen recorder status"
},
{
"value": "RemoteSession",
"label": "Remote session",
"description": "Warning while someone is connected through gliff"
},
{
"value": "Reminder",
"label": "Reminder",
+1 -1
View File
@@ -8,7 +8,7 @@ BarWidget {
id: root
moduleName: "omarchy.indicators"
readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ]
readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "ScreenRecording", "RemoteSession", "Reminder", "NightLight", "Dnd", "StayAwake" ]
readonly property var indicatorEntries: indicatorEntriesFromSettings(settings)
property var activeIndicatorIds: []
property var indicatorActiveStates: ({})
+26 -5
View File
@@ -2,6 +2,7 @@ import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.Ui
import qs.Commons
BarWidget {
id: root
@@ -9,15 +10,28 @@ BarWidget {
readonly property var source: Pipewire.defaultAudioSource
readonly property bool muted: source && source.audio ? source.audio.muted : true
readonly property real volume: source && source.audio ? source.audio.volume : 0
// A virtual source is untyped in Quickshell, so it has no PwNode.audio; its
// volume and mute go through wpctl (UntypedInput).
readonly property bool viaWpctl: UntypedInput.active
readonly property bool muted: viaWpctl ? (!UntypedInput.known || UntypedInput.muted) : (source && source.audio ? source.audio.muted : true)
readonly property real volume: viaWpctl ? UntypedInput.volume : (source && source.audio ? source.audio.volume : 0)
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
readonly property var nodeNames: {
var names = []
for (var i = 0; i < nodes.length; i++)
if (nodes[i] && nodes[i].name) names.push(String(nodes[i].name))
return names
}
readonly property var activeStreams: {
var list = []
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i]
if (node && node.isStream && node.isSink === false && !node.audio?.muted) list.push(node)
if (!node || !node.isStream || node.isSink !== false || node.audio?.muted) continue
// The shell's own level meters, and a platform's audio processing that
// records all the time, are not an app using the microphone.
if (AudioNodes.isShellLevelMeter(node.name) || AudioNodes.platformHides(node.name, nodeNames)) continue
list.push(node)
}
return list
}
@@ -29,7 +43,10 @@ BarWidget {
implicitHeight: button.implicitHeight
function toggleMute() {
if (source && source.audio) source.audio.muted = !source.audio.muted
if (viaWpctl) {
if (UntypedInput.known) UntypedInput.setMuted(!UntypedInput.muted)
}
else if (source && source.audio) source.audio.muted = !source.audio.muted
}
PwObjectTracker { objects: root.source ? [root.source] : [] }
@@ -46,8 +63,12 @@ BarWidget {
else root.toggleMute()
}
onWheelMoved: function(delta) {
if (!root.source || !root.source.audio) return
var step = 0.05
if (root.viaWpctl) {
if (UntypedInput.known) UntypedInput.setVolume(root.volume + (delta > 0 ? step : -step))
return
}
if (!root.source || !root.source.audio) return
root.source.audio.volume = Math.max(0, Math.min(1, root.volume + (delta > 0 ? step : -step)))
}
}
@@ -421,6 +421,19 @@ function popupExpired(entry, duration, now) {
return (Number(now) - Number((entry || {}).timestamp || 0)) >= lifetime
}
// How far a toast on a screen keeps from a top or right bar: the bar's
// thickness on that screen (a notch floor can make a top bar thicker than its
// configured size on one screen), plus the gap. A bar without per-screen
// sizes counts its configured size; a hidden or missing one, the default size.
function barClearance(bar, screenName, defaultBarSize, gapsOut) {
var size = Number(defaultBarSize)
if (bar && !bar.barHidden) {
size = Number(typeof bar.barSizeFor === "function" ? bar.barSizeFor(screenName) : bar.barSize)
size = isFinite(size) ? Math.max(0, size) : 0
}
return size + Number(gapsOut)
}
function popupPlacement(barPosition, barClearance, gapsOut) {
var position = String(barPosition || "top")
var clearance = Number(barClearance)
@@ -501,6 +514,7 @@ if (typeof module !== "undefined") {
serializePopup: serializePopup,
parsePopupFiles: parsePopupFiles,
popupExpired: popupExpired,
barClearance: barClearance,
popupPlacement: popupPlacement
}
}
+7 -3
View File
@@ -45,11 +45,15 @@ Item {
// pull notification popups away from the expected top-right location.
// Falls back to the bar's default size (26 horizontal / 28 vertical) when
// shell.bar isn't reachable so the popup never lands on top of the bar.
// Each screen clears the bar as thick as it is there: a notch floor can make
// a top bar taller than its configured size on one screen only.
readonly property string barPosition: shell && shell.barConfig ? String(shell.barConfig.position || "top") : "top"
readonly property bool barVertical: barPosition === "left" || barPosition === "right"
readonly property int defaultBarSize: barVertical ? Style.bar.sizeVertical : Style.bar.sizeHorizontal
readonly property int liveBarSize: shell && shell.bar && !shell.bar.barHidden ? Math.max(0, shell.bar.barSize) : defaultBarSize
readonly property int barClearance: liveBarSize + Style.gapsOut
function barClearanceFor(screenName) {
return NotificationLogic.barClearance(shell ? shell.bar : null, screenName, defaultBarSize, Style.gapsOut)
}
// Live Notification objects by originalId, kept OUT of the ListModels: a
// QObject stored in a model role becomes a dangling C++ pointer when the
@@ -1026,7 +1030,7 @@ Item {
color: "transparent"
readonly property var popupPlacement: NotificationLogic.popupPlacement(
service.barPosition, service.barClearance, Style.gapsOut)
service.barPosition, service.barClearanceFor(modelData ? modelData.name : ""), Style.gapsOut)
// Full-screen, fixed-size surface (like the OSD overlay). Adding or
// removing a toast changes only the content inside; the Wayland surface
+12 -1
View File
@@ -256,6 +256,16 @@ function streamRepresentsPlayer(node, player, players, streams) {
return streamRepresentsMprisPlayer(streamLabel(node, players, streams), playerLabel)
}
// Quickshell types only exact media classes, so a virtual source (an
// Audio/Source/Virtual, such as EasyEffects' or a platform's microphone
// mapping) is a node with no PwNode.audio. PulseAudio lists it as a source, so
// the source availability listing names it; a non-stream node it names is one.
function isUntypedSource(node, sourceAvailability) {
if (!node || node.audio || node.isStream || node.isSink) return false
var name = String(node.name || "")
return name !== "" && !!sourceAvailability && Object.prototype.hasOwnProperty.call(sourceAvailability, name)
}
if (typeof module !== "undefined") {
module.exports = {
isPlaybackStream: isPlaybackStream,
@@ -281,6 +291,7 @@ if (typeof module !== "undefined") {
matchingMprisStreamLabel: matchingMprisStreamLabel,
unmatchedMprisStreamLabel: unmatchedMprisStreamLabel,
streamLabel: streamLabel,
streamRepresentsPlayer: streamRepresentsPlayer
streamRepresentsPlayer: streamRepresentsPlayer,
isUntypedSource: isUntypedSource
}
}
+67 -18
View File
@@ -21,11 +21,25 @@ Panel {
readonly property var mediaService: bar?.shell?.firstPartyServiceFor("omarchy.media")
readonly property var activeMediaPlayer: mediaService ? mediaService.activePlayer : null
// Nodes of a platform's audio processing, which are neither devices nor apps,
// stay out of every list (AudioNodes.platformHides). Most machines have none.
readonly property var nodeNames: {
var names = []
for (var i = 0; i < nodes.length; i++)
if (nodes[i] && nodes[i].name) names.push(String(nodes[i].name))
return names
}
function platformHides(node) {
return !!node && AudioNodes.platformHides(node.name, nodeNames)
}
readonly property var candidateSinks: {
var list = []
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i]
if (n && n.isSink && !n.isStream) list.push(n)
if (!n || !n.isSink || n.isStream || platformHides(n)) continue
list.push(n)
}
return list
}
@@ -34,11 +48,11 @@ Panel {
var list = []
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i]
if (n && !n.isSink && !n.isStream && isAudioSource(n)) {
var name = n.name || ""
if (name === "quickshell") continue
list.push(n)
}
if (!n || n.isSink || n.isStream) continue
if (!isAudioSource(n) && !Model.isUntypedSource(n, sourceAvailability)) continue
var name = n.name || ""
if (name === "quickshell" || platformHides(n)) continue
list.push(n)
}
return list
}
@@ -51,6 +65,7 @@ Panel {
// A tuning's output is a playback stream too, but it is the processing
// itself rather than an application, so it does not belong in the list.
if (String(n.name || "").indexOf("omarchy_speaker_tuning") === 0) continue
if (platformHides(n)) continue
list.push(n)
}
return list
@@ -58,6 +73,7 @@ Panel {
property var sinkAvailability: ({})
property bool sinkAvailabilityLoaded: false
property var sourceAvailability: ({})
// Identify true playback streams without reading node.properties here:
// PwNode.properties is invalid until the node is bound, and reading it while
@@ -148,8 +164,15 @@ Panel {
readonly property real outputVolume: volumeSink && volumeSink.audio ? volumeSink.audio.volume : 0
readonly property bool outputMuted: volumeSink && volumeSink.audio ? volumeSink.audio.muted : false
readonly property real inputVolume: source && source.audio ? source.audio.volume : 0
readonly property bool inputMuted: source && source.audio ? source.audio.muted : false
// A virtual source is untyped in Quickshell (see Model.isUntypedSource), so
// its volume and mute go through wpctl (UntypedInput), and the panel shows no
// level meter for it: Quickshell's peak monitor takes typed nodes only.
readonly property bool inputViaWpctl: UntypedInput.active
readonly property bool inputLevelKnown: !inputViaWpctl || UntypedInput.known
readonly property real inputVolume: inputViaWpctl ? UntypedInput.volume : (source && source.audio ? source.audio.volume : 0)
readonly property bool inputMuted: inputViaWpctl ? UntypedInput.muted : (source && source.audio ? source.audio.muted : false)
readonly property var inputPeakNode: inputViaWpctl ? null : source
readonly property bool inputLevelShown: !!inputPeakNode
onRawAudioSinksChanged: if (rawAudioSinks.length > 0) cachedAudioSinks = rawAudioSinks
onRawAudioSourcesChanged: if (rawAudioSources.length > 0) cachedAudioSources = rawAudioSources
@@ -175,7 +198,7 @@ Panel {
// would otherwise report "input unmuted" forever, leaving the hero switch
// able to mute but never to unmute.
readonly property bool hasOutput: !!(volumeSink && volumeSink.audio)
readonly property bool hasInput: !!(source && source.audio)
readonly property bool hasInput: !!(source && source.audio) || (inputViaWpctl && UntypedInput.known)
readonly property bool anyAudible: (hasOutput && !outputMuted) || (hasInput && !inputMuted)
readonly property string toggleHint: anyAudible ? "Mute" : "Unmute"
@@ -427,7 +450,7 @@ Panel {
}
function inputIcon() {
if (!source || !source.audio) return "󰍭"
if (!hasInput) return "󰍭"
return inputMuted ? "󰍭" : "󰍬"
}
@@ -454,6 +477,10 @@ Panel {
}
function setInputVolume(v) {
if (inputViaWpctl) {
UntypedInput.setVolume(v)
return
}
if (!source || !source.audio) return
source.audio.volume = Math.max(0, Math.min(1, v))
}
@@ -463,7 +490,10 @@ Panel {
}
function toggleInputMute() {
if (source && source.audio) source.audio.muted = !source.audio.muted
if (inputViaWpctl) {
if (UntypedInput.known) UntypedInput.setMuted(!UntypedInput.muted)
}
else if (source && source.audio) source.audio.muted = !source.audio.muted
}
// The hero switch is the whole panel's on/off, so it carries both channels
@@ -472,7 +502,8 @@ Panel {
function toggleAllMuted() {
var mute = anyAudible
if (hasOutput) volumeSink.audio.muted = mute
if (hasInput) source.audio.muted = mute
if (hasInput && inputViaWpctl) UntypedInput.setMuted(mute)
else if (hasInput) source.audio.muted = mute
}
function setDefaultSink(node) {
@@ -489,7 +520,8 @@ Panel {
function setDefaultSource(node) {
if (!node) return
Pipewire.preferredDefaultAudioSource = node
// Quickshell refuses an untyped node; the command below still selects it.
if (node.audio) Pipewire.preferredDefaultAudioSource = node
if (node.id !== undefined && node.name) {
Quickshell.execDetached([
"omarchy-audio-input-set-default",
@@ -597,8 +629,8 @@ Panel {
PwNodePeakMonitor {
id: inputPeakMonitor
node: root.source
enabled: root.opened && !!root.source
node: root.inputPeakNode
enabled: root.opened && !!root.inputPeakNode
}
Process {
@@ -610,6 +642,15 @@ Panel {
}
}
Process {
id: sourceAvailabilityProc
command: ["omarchy-audio-sink-availability", "sources"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.sourceAvailability = Model.parseSinkAvailability(text)
}
}
Process {
id: volumeSinkProc
command: ["omarchy-audio-output-sink"]
@@ -624,7 +665,10 @@ Panel {
running: root.opened
repeat: true
triggeredOnStart: true
onTriggered: if (!sinkAvailabilityProc.running) sinkAvailabilityProc.running = true
onTriggered: {
if (!sinkAvailabilityProc.running) sinkAvailabilityProc.running = true
if (!sourceAvailabilityProc.running) sourceAvailabilityProc.running = true
}
}
// Runs whether or not the panel is open: the bar shows and scrolls the output
@@ -908,7 +952,7 @@ Panel {
Text {
id: microphonePercent
textFormat: Text.PlainText
text: Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%"
text: root.inputLevelKnown ? Math.round((inputSlider.dragging ? inputSlider.liveValue : root.inputVolume) * 100) + "%" : "–"
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
@@ -932,9 +976,13 @@ Panel {
Column {
id: inputControls
anchors.fill: parent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: Style.space(6)
anchors.rightMargin: Style.space(6)
// Alone, the slider centres in the row the way the output slider does.
anchors.topMargin: root.inputLevelShown ? 0 : Style.spacing.controlGap / 2
spacing: Style.space(5)
PanelSlider {
@@ -953,6 +1001,7 @@ Panel {
}
Rectangle {
visible: root.inputLevelShown
width: parent.width
height: Math.max(Style.space(5), Style.spacing.xs)
color: Util.alpha(root.bar.foreground, 0.18)
@@ -0,0 +1,31 @@
// Hyprland emits this whenever a screencopy session starts or stops, which
// covers gliff-server (ext-image-copy-capture) as well as screen recorders
// and browser screen shares, so the event is only a cue to re-probe. The
// matching screencastv2 event always accompanies it and is left alone so one
// transition costs one probe.
function isCaptureEvent(name) {
return String(name || "") === "screencast"
}
// Parses the probe output: one "<pid> [peer]" line per running gliff-server,
// where peer is the ssh client address when the server was spawned over ssh.
function stateFromOutput(text) {
var lines = String(text || "").split("\n")
var active = false
var peers = []
for (var i = 0; i < lines.length; i++) {
var parts = lines[i].trim().split(/\s+/)
if (parts[0] === "") continue
active = true
var peer = parts[1] || ""
if (peer !== "" && peers.indexOf(peer) === -1) peers.push(peer)
}
return { active: active, peers: peers }
}
if (typeof module !== "undefined") {
module.exports = {
isCaptureEvent: isCaptureEvent,
stateFromOutput: stateFromOutput
}
}
@@ -0,0 +1,79 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import "RemoteSessionModel.js" as RemoteSessionModel
// Watches for gliff-server, the Hyprland remote desktop, so the machine being
// controlled can see that someone is on it: the RemoteSession bar indicator
// reads `active` and `peers`.
Item {
id: root
// Injected by omarchy-shell (the first-party service loader).
property var shell: null
property bool stateLoaded: false
property bool active: false
property var peers: []
property bool refreshPending: false
function refresh() {
if (statusProbe.running) {
root.refreshPending = true
return
}
root.refreshPending = false
statusProbe.running = true
}
function applyProbe(text) {
var state = RemoteSessionModel.stateFromOutput(text)
root.peers = state.peers
root.active = state.active
root.stateLoaded = true
}
Component.onCompleted: refresh()
// gliff-server captures the screen through ext-image-copy-capture, so a
// session start surfaces as a Hyprland screencast event. The probe decides
// whether it was gliff or another screencopy client, and a short debounce
// folds the event bursts Hyprland sends around one transition into one probe.
Connections {
target: Hyprland
function onRawEvent(event) {
if (RemoteSessionModel.isCaptureEvent(event ? event.name : "")) eventDebounce.restart()
}
}
Timer {
id: eventDebounce
interval: 150
repeat: false
onTriggered: root.refresh()
}
// The end of a session often has no event: Hyprland reports the screencast
// stopped as soon as frames pause for half a second, and says nothing more
// when the server later exits. So poll while a session is active.
Timer {
interval: 2000
repeat: true
running: root.active
onTriggered: root.refresh()
}
Process {
id: statusProbe
command: ["bash", Quickshell.env("OMARCHY_PATH") + "/shell/plugins/services/remote-session/probe.sh"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.applyProbe(text)
}
onExited: function() {
if (root.refreshPending) Qt.callLater(root.refresh)
}
}
}
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "omarchy.remote-session",
"name": "Remote Session",
"version": "1.0.0",
"author": "Omarchy",
"description": "Detects a gliff remote-desktop session on this machine for the bar indicator.",
"kinds": [
"service"
],
"entryPoints": {
"service": "Service.qml"
}
}
+11
View File
@@ -0,0 +1,11 @@
#!/bin/bash
# Lists this user's running gliff-server processes, one "<pid> [peer]" line
# each. The peer is the ssh client address when the server was spawned over
# ssh (the real path); a local --listen server has none. Another account's
# server captures its own desktop, not this one, so other users are skipped.
for pid in $(pgrep -x -u "$(id -u)" gliff-server); do
peer=$(tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=\([^ ]*\).*/\1/p')
printf '%s %s\n' "$pid" "$peer"
done
+11
View File
@@ -6,7 +6,18 @@ QtObject {
required property string ownerPluginId
property bool barHidden: false
// The configured size. A notch floor can make a top bar thicker on one
// screen; barSizes holds each screen's thickness by screen name.
property int barSize: 0
property var barSizes: ({})
property string fontFamily: ""
property string position: "top"
// The bar's thickness on the screen with this name, to clear the bar by;
// barSize for a screen the bar reports none for.
function barSizeFor(screenName) {
var name = String(screenName || "")
var size = barSizes && Object.prototype.hasOwnProperty.call(barSizes, name) ? Number(barSizes[name]) : 0
return size > 0 ? size : barSize
}
}
@@ -11,6 +11,8 @@ QtObject {
property bool doNotDisturb: false
property var activePlayer: null
property var sourcePlayers: []
property bool active: false
property var peers: []
property var _setIdleEnabled: null
property var _setNightlight: null
@@ -18,6 +20,7 @@ QtObject {
property var _runAction: null
property var _playerKey: null
property var _selectPlayer: null
property var _refresh: null
function setIdleEnabled(value) {
if (serviceId === "omarchy.idle" && _setIdleEnabled) _setIdleEnabled(!!value)
@@ -43,4 +46,8 @@ QtObject {
function selectPlayer(playerId) {
if (serviceId === "omarchy.media" && _selectPlayer) _selectPlayer(String(playerId || ""))
}
function refresh() {
if (serviceId === "omarchy.remote-session" && _refresh) _refresh()
}
}
+29 -2
View File
@@ -1,3 +1,4 @@
//@ pragma OmarchyLoadPatch
import QtQuick
import QtQml.Models
import Quickshell
@@ -439,12 +440,26 @@ ShellRoot {
return api
}
// A copy of the active bar's per-screen thickness (screen name -> number),
// so a plugin holds no reference into the bar. A bar that reports none
// gives an empty map.
function detachedBarSizes(sizes) {
var copy = ({})
if (!Util.isPlainObject(sizes)) return copy
for (var name in sizes) {
var size = Number(sizes[name])
if (size > 0) copy[name] = size
}
return copy
}
function pluginBarStateFor(cacheKey, pluginId) {
if (_pluginBarStateApis[cacheKey]) return _pluginBarStateApis[cacheKey]
var api = pluginBarStateApiComponent.createObject(null, { ownerPluginId: pluginId })
if (!api) return null
api.barHidden = Qt.binding(function() { return shell.bar ? shell.bar.barHidden === true : false })
api.barSize = Qt.binding(function() { return shell.bar ? Math.max(0, shell.bar.barSize || 0) : 0 })
api.barSizes = Qt.binding(function() { return shell.bar ? shell.detachedBarSizes(shell.bar.screenBarSizes) : ({}) })
api.fontFamily = Qt.binding(function() { return shell.bar ? String(shell.bar.fontFamily || "") : "" })
api.position = Qt.binding(function() { return shell.bar ? String(shell.bar.position || "top") : "top" })
var next = ({})
@@ -456,7 +471,7 @@ ShellRoot {
function pluginFirstPartyServiceFor(cacheKey, pluginId, requestedId) {
var id = String(requestedId || "")
var allowed = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"]
var allowed = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications", "omarchy.remote-session"]
if (allowed.indexOf(id) === -1) return null
var proxyKey = cacheKey + "::" + id
if (_pluginFirstPartyServiceApis[proxyKey]) return _pluginFirstPartyServiceApis[proxyKey]
@@ -490,6 +505,10 @@ ShellRoot {
_selectPlayer: function(playerKey) {
var target = service()
if (target && typeof target.selectPlayer === "function") target.selectPlayer(playerKey)
},
_refresh: function() {
var target = service()
if (target && typeof target.refresh === "function") target.refresh()
}
})
if (!api) return null
@@ -513,6 +532,14 @@ ShellRoot {
var target = service()
return target && Array.isArray(target.sourcePlayers) ? target.sourcePlayers : []
})
api.active = Qt.binding(function() {
var target = service()
return target ? target.active === true : false
})
api.peers = Qt.binding(function() {
var target = service()
return target && Array.isArray(target.peers) ? target.peers : []
})
var next = ({})
for (var existing in _pluginFirstPartyServiceApis) next[existing] = _pluginFirstPartyServiceApis[existing]
next[proxyKey] = api
@@ -588,7 +615,7 @@ ShellRoot {
// property, even though the resulting proxy is otherwise acyclic.
var firstPartyServices = ({})
if (barCapabilities) {
var serviceIds = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"]
var serviceIds = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications", "omarchy.remote-session"]
for (var i = 0; i < serviceIds.length; i++) {
var serviceId = serviceIds[i]
firstPartyServices[serviceId] = shell.pluginFirstPartyServiceFor(cacheKey, key, serviceId)