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

+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